forked from andyburke/autonomous.contact
227 lines
6.4 KiB
TypeScript
227 lines
6.4 KiB
TypeScript
import { by_character, by_lurid } from '@andyburke/fsdb/organizers';
|
|
import { FSDB_COLLECTION } from '@andyburke/fsdb';
|
|
import { FSDB_INDEXER_SYMLINKS } from '@andyburke/fsdb/indexers';
|
|
import { EMOJI_MAP as JS_EMOJI_MAP } from '../public/js/emojis/en.js';
|
|
|
|
const EMOJI_MAP: Record<string, string[]> = JS_EMOJI_MAP;
|
|
|
|
/**
|
|
* @typedef {object} TIMESTAMPS
|
|
* @property {string} created when the event was created
|
|
* @property {string} updated when the event was last updated
|
|
*/
|
|
|
|
/**
|
|
* Event
|
|
*
|
|
* @property {string} id - lurid
|
|
* @property {string} creator_id - id of the source user
|
|
* @property {string} type - event type
|
|
* @property {string} [parent_id] - optional parent event id
|
|
* @property {string[]} [tags] - optional event tags
|
|
* @property {Record<string,any>} [data] - optional data payload of the event
|
|
* @property {TIMESTAMPS} timestamps - timestamps that will be set by the server
|
|
*/
|
|
export type EVENT = {
|
|
id: string;
|
|
creator_id: string;
|
|
type: string;
|
|
parent_id?: string;
|
|
tags?: string[];
|
|
data?: Record<string, any>;
|
|
timestamps: {
|
|
created: string;
|
|
updated: string;
|
|
};
|
|
};
|
|
|
|
type TOPIC_EVENT_CACHE_ENTRY = {
|
|
collection: FSDB_COLLECTION<EVENT>;
|
|
eviction_timeout: number;
|
|
};
|
|
|
|
// TODO: separate out these different validators somewhere?
|
|
export function VALIDATE_EVENT(event: EVENT) {
|
|
const errors: any[] = [];
|
|
|
|
const [type, id] = (event.id ?? '').split(':', 2);
|
|
|
|
if (typeof type !== 'string' || type.length === 0) {
|
|
errors.push({
|
|
cause: 'missing_event_type_in_id',
|
|
message: 'An event must have a type that is also encoded into its id, eg: chat:able-fish-wife...'
|
|
});
|
|
}
|
|
|
|
if (typeof id !== 'string' || id.length !== 49) {
|
|
errors.push({
|
|
cause: 'invalid_event_id',
|
|
message: 'An event must have a type and a lurid id, eg: chat:able-fish-gold-wing-trip-form-seed-cost-rope-wife'
|
|
});
|
|
}
|
|
|
|
switch (event.type) {
|
|
case 'chat':
|
|
if (event.data?.message?.length <= 0) {
|
|
errors.push({
|
|
cause: 'chat_message_missing',
|
|
message: 'A chat message event cannot be empty.'
|
|
});
|
|
}
|
|
break;
|
|
case 'post':
|
|
if (event.data?.subject?.length <= 0) {
|
|
errors.push({
|
|
cause: 'post_missing_subject',
|
|
message: 'A post cannot have an empty subject.'
|
|
});
|
|
}
|
|
break;
|
|
case 'blurb':
|
|
if (event.data?.blurb?.length <= 0) {
|
|
errors.push({
|
|
cause: 'blurb_missing',
|
|
message: 'A blurb cannot be empty.'
|
|
});
|
|
} else if (event.data?.blurb?.length > 2 ** 8) {
|
|
errors.push({
|
|
cause: 'blurb_length_limit_exceeded',
|
|
message: 'A blurb cannot be longer than 256 characters.'
|
|
});
|
|
}
|
|
break;
|
|
case 'essay':
|
|
if (event.data?.title?.length <= 0) {
|
|
errors.push({
|
|
cause: 'essay_title_missing',
|
|
message: 'An essay must have a title.'
|
|
});
|
|
} else if (event.data?.title?.length > 2 ** 7) {
|
|
errors.push({
|
|
cause: 'essay_title_length_limit_exceeded',
|
|
message: 'An essay title cannot be longer than 128 characters.'
|
|
});
|
|
} else if (event.data?.essay?.length <= 0) {
|
|
errors.push({
|
|
cause: 'essay_missing',
|
|
message: 'An essay cannot be empty.'
|
|
});
|
|
} else if (event.data?.essay?.length > 2 ** 16) {
|
|
errors.push({
|
|
cause: 'essay_length_limit_exceeded',
|
|
message: 'An essay cannot be longer than 65536 characters - that would be a screed, which we do not yet support.'
|
|
});
|
|
}
|
|
break;
|
|
case 'reaction':
|
|
if (typeof event.parent_id !== 'string') {
|
|
errors.push({
|
|
cause: 'reaction_missing_parent_id',
|
|
message: 'A reaction must have a parent_id that refers to another event.'
|
|
});
|
|
} else if (typeof event.data?.reaction !== 'string') {
|
|
errors.push({
|
|
cause: 'reaction_must_be_an_emoji',
|
|
message: 'A reaction event must have a `data.reaction` that is an emoji.'
|
|
});
|
|
} else if (typeof EMOJI_MAP[event.data.reaction] === 'undefined') {
|
|
errors.push({
|
|
cause: 'reaction_must_be_an_emoji',
|
|
message: 'A reaction event must have a `data.reaction` that is an emoji.'
|
|
});
|
|
}
|
|
break;
|
|
case 'test':
|
|
if (Deno.env.get('DENO_ENV') !== 'test') {
|
|
errors.push({
|
|
cause: 'unknown_event_type',
|
|
message: 'Event types allowed: ' + ['chat', 'post', 'blurb', 'essay', 'reaction'].join(', ')
|
|
});
|
|
}
|
|
break;
|
|
default:
|
|
errors.push({
|
|
cause: 'unknown_event_type',
|
|
message: 'Event types allowed: ' + ['chat', 'post', 'blurb', 'essay', 'reaction'].join(', ')
|
|
});
|
|
break;
|
|
}
|
|
|
|
return errors.length ? errors : undefined;
|
|
}
|
|
|
|
const TOPIC_EVENT_ID_MATCHER = /^(?<event_type>.*):(?<event_id>.*)$/;
|
|
|
|
const TOPIC_EVENTS: Record<string, TOPIC_EVENT_CACHE_ENTRY> = {};
|
|
export function get_events_collection_for_topic(topic_id: string): FSDB_COLLECTION<EVENT> {
|
|
TOPIC_EVENTS[topic_id] = TOPIC_EVENTS[topic_id] ?? {
|
|
collection: new FSDB_COLLECTION<EVENT>({
|
|
name: `topics/${topic_id.slice(0, 14)}/${topic_id.slice(0, 34)}/${topic_id}/events`,
|
|
id_field: 'id',
|
|
organize: (id) => {
|
|
TOPIC_EVENT_ID_MATCHER.lastIndex = 0;
|
|
|
|
const groups: Record<string, string> | undefined = TOPIC_EVENT_ID_MATCHER.exec(id ?? '')?.groups;
|
|
|
|
if (!groups) {
|
|
throw new Error('Could not parse event id: ' + id);
|
|
}
|
|
|
|
const event_type = groups.event_type;
|
|
const event_id = groups.event_id;
|
|
|
|
return [
|
|
event_type,
|
|
event_id.slice(0, 14),
|
|
event_id.slice(0, 34),
|
|
event_id,
|
|
`${event_id}.json` /* TODO: this should be ${id}.json - need to write a converter */
|
|
];
|
|
},
|
|
indexers: {
|
|
creator_id: new FSDB_INDEXER_SYMLINKS<EVENT>({
|
|
name: 'creator_id',
|
|
field: 'creator_id',
|
|
to_many: true,
|
|
organize: by_lurid
|
|
}),
|
|
|
|
parent_id: new FSDB_INDEXER_SYMLINKS<EVENT>({
|
|
name: 'parent_id',
|
|
field: 'parent_id',
|
|
to_many: true,
|
|
organize: by_lurid
|
|
}),
|
|
|
|
tags: new FSDB_INDEXER_SYMLINKS<EVENT>({
|
|
name: 'tags',
|
|
get_values_to_index: (event: EVENT): string[] => {
|
|
return (event.tags ?? []).map((tag: string) => tag.toLowerCase());
|
|
},
|
|
to_many: true,
|
|
organize: by_character
|
|
})
|
|
}
|
|
}),
|
|
eviction_timeout: 0
|
|
};
|
|
|
|
if (TOPIC_EVENTS[topic_id].eviction_timeout) {
|
|
clearTimeout(TOPIC_EVENTS[topic_id].eviction_timeout);
|
|
}
|
|
|
|
TOPIC_EVENTS[topic_id].eviction_timeout = setTimeout(() => {
|
|
delete TOPIC_EVENTS[topic_id];
|
|
}, 60_000 * 5);
|
|
|
|
return TOPIC_EVENTS[topic_id].collection;
|
|
}
|
|
|
|
export function clear_topic_events_cache() {
|
|
for (const [topic_id, cached] of Object.entries(TOPIC_EVENTS)) {
|
|
if (cached.eviction_timeout) {
|
|
clearTimeout(cached.eviction_timeout);
|
|
}
|
|
delete TOPIC_EVENTS[topic_id];
|
|
}
|
|
}
|