refactor: rooms => zones

This commit is contained in:
Andy Burke 2025-09-09 15:32:07 -07:00
parent 525568d368
commit 20a5d1bc88
27 changed files with 2466 additions and 2044 deletions

View file

@ -0,0 +1,23 @@
# /api/zones/:zone_id
Interact with a specific zone.
## GET /api/zones/:zone_id
Get the zone specified by `:zone_id`.
## PUT /api/zones/:zone_id
Update the zones specified by `:zone_id`.
Eg:
```
{
name?: string;
}
```
## DELETE /api/zones/:zone_id
Delete the zone specified by `:zone_id`.

View file

@ -0,0 +1,166 @@
import { FSDB_COLLECTION } from '@andyburke/fsdb';
import { EVENT, get_events_collection_for_zone } from '../../../../../../models/event.ts';
import { ZONE, ZONES } from '../../../../../../models/zone.ts';
import parse_body from '../../../../../../utils/bodyparser.ts';
import * as CANNED_RESPONSES from '../../../../../../utils/canned_responses.ts';
import { get_session, get_user, PRECHECK_TABLE, require_user } from '../../../../../../utils/prechecks.ts';
export const PRECHECKS: PRECHECK_TABLE = {};
// GET /api/zones/:zone_id/events/:id - Get an event
PRECHECKS.GET = [get_session, get_user, require_user, async (_req: Request, meta: Record<string, any>): Promise<Response | undefined> => {
const zone_id: string = meta.params?.zone_id?.toLowerCase().trim() ?? '';
// lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same"
const zone: ZONE | null = zone_id.length === 49 ? await ZONES.get(zone_id) : null;
if (!zone) {
return CANNED_RESPONSES.not_found();
}
meta.zone = zone;
const zone_is_public = zone.permissions.read.length === 0;
const user_has_read_for_zone = zone_is_public || zone.permissions.read.includes(meta.user.id);
const zone_has_public_events = user_has_read_for_zone && (zone.permissions.read_events.length === 0);
const user_has_read_events_for_zone = user_has_read_for_zone &&
(zone_has_public_events || zone.permissions.read_events.includes(meta.user.id));
if (!user_has_read_events_for_zone) {
return CANNED_RESPONSES.permission_denied();
}
}];
export async function GET(_req: Request, meta: Record<string, any>): Promise<Response> {
const events: FSDB_COLLECTION<EVENT> = get_events_collection_for_zone(meta.zone.id);
const event: EVENT | null = await events.get(meta.params.event_id);
if (!event) {
return CANNED_RESPONSES.not_found();
}
return Response.json(event, {
status: 200
});
}
// PUT /api/zones/:zone_id/events/:event_id - Update event
PRECHECKS.PUT = [
get_session,
get_user,
require_user,
(_req: Request, _meta: Record<string, any>): Response | undefined => {
if (Deno.env.get('APPEND_ONLY_EVENTS')) {
return CANNED_RESPONSES.append_only_events();
}
},
async (_req: Request, meta: Record<string, any>): Promise<Response | undefined> => {
const zone_id: string = meta.params?.zone_id?.toLowerCase().trim() ?? '';
// lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same"
const zone: ZONE | null = zone_id.length === 49 ? await ZONES.get(zone_id) : null;
if (!zone) {
return CANNED_RESPONSES.not_found();
}
meta.zone = zone;
const zone_is_public: boolean = meta.zone.permissions.read.length === 0;
const user_has_read_for_zone = zone_is_public || meta.zone.permissions.read.includes(meta.user.id);
const zone_events_are_publicly_writable = meta.zone.permissions.write_events.length === 0;
const user_has_write_events_for_zone = user_has_read_for_zone &&
(zone_events_are_publicly_writable || meta.zone.permissions.write_events.includes(meta.user.id));
if (!user_has_write_events_for_zone) {
return CANNED_RESPONSES.permission_denied();
}
}
];
export async function PUT(req: Request, meta: Record<string, any>): Promise<Response> {
const now = new Date().toISOString();
try {
const events: FSDB_COLLECTION<EVENT> = get_events_collection_for_zone(meta.zone.id);
const event: EVENT | null = await events.get(meta.params.event_id);
if (!event) {
return CANNED_RESPONSES.not_found();
}
if (event.creator_id !== meta.user.id) {
return CANNED_RESPONSES.permission_denied();
}
const body = await parse_body(req);
const updated = {
...event,
...body,
id: event.id,
creator_id: event.creator_id,
timestamps: {
created: event.timestamps.created,
updated: now
}
};
await events.update(updated);
return Response.json(updated, {
status: 200
});
} catch (err) {
return Response.json({
error: {
message: (err as Error)?.message ?? 'Unknown error due to invalid data.',
cause: (err as Error)?.cause ?? 'invalid_data'
}
}, {
status: 400
});
}
}
// DELETE /api/zones/:zone_id/events/:event_id - Delete event
PRECHECKS.DELETE = [
get_session,
get_user,
require_user,
(_req: Request, _meta: Record<string, any>): Response | undefined => {
if (Deno.env.get('APPEND_ONLY_EVENTS')) {
return CANNED_RESPONSES.append_only_events();
}
},
async (_req: Request, meta: Record<string, any>): Promise<Response | undefined> => {
const zone_id: string = meta.params?.zone_id?.toLowerCase().trim() ?? '';
// lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same"
const zone: ZONE | null = zone_id.length === 49 ? await ZONES.get(zone_id) : null;
if (!zone) {
return CANNED_RESPONSES.not_found();
}
meta.zone = zone;
const zone_is_public: boolean = meta.zone.permissions.read.length === 0;
const user_has_read_for_zone = zone_is_public || meta.zone.permissions.read.includes(meta.user.id);
const zone_events_are_publicly_writable = meta.zone.permissions.write_events.length === 0;
const user_has_write_events_for_zone = user_has_read_for_zone &&
(zone_events_are_publicly_writable || meta.zone.permissions.write_events.includes(meta.user.id));
if (!user_has_write_events_for_zone) {
return CANNED_RESPONSES.permission_denied();
}
}
];
export async function DELETE(_req: Request, meta: Record<string, any>): Promise<Response> {
const events: FSDB_COLLECTION<EVENT> = get_events_collection_for_zone(meta.zone.id);
const event: EVENT | null = await events.get(meta.params.event_id);
if (!event) {
return CANNED_RESPONSES.not_found();
}
await events.delete(event);
return Response.json({
deleted: true
}, {
status: 200
});
}

View file

@ -0,0 +1,15 @@
# /api/zones/:zone_id/events
Interact with a events for a zone.
## GET /api/zones/:zone_id/events
Get events for the given zone.
## PUT /api/zones/:zone_id/events/:event_id
Update an event.
## DELETE /api/zones/:zone_id/events/:event_id
Delete an event.

View file

@ -0,0 +1,183 @@
import lurid from '@andyburke/lurid';
import { get_session, get_user, PRECHECK_TABLE, require_user } from '../../../../../utils/prechecks.ts';
import { ZONE, ZONES } from '../../../../../models/zone.ts';
import * as CANNED_RESPONSES from '../../../../../utils/canned_responses.ts';
import { EVENT, get_events_collection_for_zone } from '../../../../../models/event.ts';
import parse_body from '../../../../../utils/bodyparser.ts';
import { FSDB_COLLECTION, FSDB_SEARCH_OPTIONS, WALK_ENTRY } from '@andyburke/fsdb';
import * as path from '@std/path';
export const PRECHECKS: PRECHECK_TABLE = {};
// GET /api/zones/:zone_id/events - get zone events
// query parameters:
// partial_id: the partial id subset you would like to match (remember, lurids are lexigraphically sorted)
PRECHECKS.GET = [get_session, get_user, require_user, async (_req: Request, meta: Record<string, any>): Promise<Response | undefined> => {
const zone_id: string = meta.params?.zone_id?.toLowerCase().trim() ?? '';
// lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same"
const zone: ZONE | null = zone_id.length === 49 ? await ZONES.get(zone_id) : null;
if (!zone) {
return CANNED_RESPONSES.not_found();
}
meta.zone = zone;
const zone_is_public: boolean = meta.zone.permissions.read.length === 0;
const user_has_read_for_zone = zone_is_public || meta.zone.permissions.read.includes(meta.user.id);
const zone_events_are_public = meta.zone.permissions.read_events.length === 0;
const user_has_read_events_for_zone = user_has_read_for_zone &&
(zone_events_are_public || meta.zone.permissions.read_events.includes(meta.user.id));
if (!user_has_read_events_for_zone) {
return CANNED_RESPONSES.permission_denied();
}
}];
export async function GET(request: Request, meta: Record<string, any>): Promise<Response> {
const events: FSDB_COLLECTION<EVENT> = get_events_collection_for_zone(meta.zone.id);
const sorts = events.sorts;
const sort_name: string = meta.query.sort ?? 'newest';
const key = sort_name as keyof typeof sorts;
const sort: any = sorts[key];
if (!sort) {
return Response.json({
error: {
message: 'You must specify a sort: newest, oldest, latest, stalest',
cause: 'invalid_sort'
}
}, {
status: 400
});
}
const options: FSDB_SEARCH_OPTIONS<EVENT> = {
...(meta.query ?? {}),
limit: Math.min(parseInt(meta.query?.limit ?? '10'), 1_000),
sort,
filter: (entry: WALK_ENTRY<EVENT>) => {
const [event_id, event_type] = path.basename(entry.path).replace(/\.json$/i, '').split(':');
if (meta.query.after_id && event_id <= meta.query.after_id) {
return false;
}
if (meta.query.before_id && event_id >= meta.query.before_id) {
return false;
}
if (meta.query.type && event_type !== meta.query.type) {
return false;
}
return true;
}
};
const headers = {
'Cache-Control': 'no-cache, must-revalidate'
};
const results = (await events.all(options))
.map((entry: WALK_ENTRY<EVENT>) => entry.load())
.sort((lhs_item: EVENT, rhs_item: EVENT) => rhs_item.timestamps.created.localeCompare(lhs_item.timestamps.created));
// long-polling support
if (results.length === 0 && meta.query.wait) {
return new Promise((resolve, reject) => {
function on_create(create_event: any) {
results.push(create_event.item);
clearTimeout(timeout);
events.off('create', on_create);
return resolve(Response.json(results, {
status: 200,
headers
}));
}
const timeout = setTimeout(() => {
events.off('create', on_create);
return resolve(Response.json(results, {
status: 200,
headers
}));
}, 60_000); // 60 seconds
events.on('create', on_create);
request.signal.addEventListener('abort', () => {
events.off('create', on_create);
clearTimeout(timeout);
reject(new Error('request aborted'));
});
Deno.addSignalListener('SIGINT', () => {
events.off('create', on_create);
clearTimeout(timeout);
return resolve(Response.json(results, {
status: 200,
headers
}));
});
});
}
return Response.json(results, {
status: 200,
headers
});
}
// POST /api/zones/:zone_id/events - Create an event
PRECHECKS.POST = [get_session, get_user, require_user, async (_req: Request, meta: Record<string, any>): Promise<Response | undefined> => {
const zone_id: string = meta.params?.zone_id?.toLowerCase().trim() ?? '';
// lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same"
const zone: ZONE | null = zone_id.length === 49 ? await ZONES.get(zone_id) : null;
if (!zone) {
return CANNED_RESPONSES.not_found();
}
meta.zone = zone;
const zone_is_public: boolean = meta.zone.permissions.read.length === 0;
const user_has_read_for_zone = zone_is_public || meta.zone.permissions.read.includes(meta.user.id);
const zone_events_are_publicly_writable = meta.zone.permissions.write_events.length === 0;
const user_has_write_events_for_zone = user_has_read_for_zone &&
(zone_events_are_publicly_writable || meta.zone.permissions.write_events.includes(meta.user.id));
if (!user_has_write_events_for_zone) {
return CANNED_RESPONSES.permission_denied();
}
}];
export async function POST(req: Request, meta: Record<string, any>): Promise<Response> {
try {
const events: FSDB_COLLECTION<EVENT> = get_events_collection_for_zone(meta.zone.id);
const now = new Date().toISOString();
const body = await parse_body(req);
const event: EVENT = {
type: 'unknown',
...body,
creator_id: meta.user.id,
timestamps: {
created: now,
updated: now
}
};
event.id = `${lurid()}:${event.type}`;
await events.create(event);
return Response.json(event, {
status: 201
});
} catch (error) {
return Response.json({
error: {
message: (error as Error).message ?? 'Unknown Error!',
cause: (error as Error).cause ?? 'unknown'
}
}, { status: 500 });
}
}

View file

@ -0,0 +1,113 @@
import { get_session, get_user, PRECHECK_TABLE, require_user } from '../../../../utils/prechecks.ts';
import parse_body from '../../../../utils/bodyparser.ts';
import * as CANNED_RESPONSES from '../../../../utils/canned_responses.ts';
import { ZONE, ZONES } from '../../../../models/zone.ts';
export const PRECHECKS: PRECHECK_TABLE = {};
// GET /api/zones/:id - Get a zone
PRECHECKS.GET = [get_session, get_user, require_user, async (_req: Request, meta: Record<string, any>): Promise<Response | undefined> => {
const zone_id: string = meta.params?.zone_id?.toLowerCase().trim() ?? '';
// lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same"
const zone: ZONE | null = zone_id.length === 49 ? await ZONES.get(zone_id) : null;
if (!zone) {
return CANNED_RESPONSES.not_found();
}
meta.zone = zone;
const zone_is_public = zone.permissions.read.length === 0;
const user_has_read_for_zone = zone_is_public || zone.permissions.read.includes(meta.user.id);
if (!user_has_read_for_zone) {
return CANNED_RESPONSES.permission_denied();
}
}];
export function GET(_req: Request, meta: Record<string, any>): Response {
return Response.json(meta.zone, {
status: 200
});
}
// PUT /api/zones/:id - Update zone
PRECHECKS.PUT = [get_session, get_user, require_user, async (_req: Request, meta: Record<string, any>): Promise<Response | undefined> => {
const zone_id: string = meta.params?.zone_id?.toLowerCase().trim() ?? '';
// lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same"
const zone: ZONE | null = zone_id.length === 49 ? await ZONES.get(zone_id) : null;
if (!zone) {
return CANNED_RESPONSES.not_found();
}
meta.zone = zone;
const user_has_write_for_zone = zone.permissions.write.includes(meta.user.id);
if (!user_has_write_for_zone) {
return CANNED_RESPONSES.permission_denied();
}
}];
export async function PUT(req: Request, meta: Record<string, any>): Promise<Response> {
const now = new Date().toISOString();
try {
const body = await parse_body(req);
const updated = {
...meta.zone,
...body,
id: meta.zone.id,
timestamps: {
created: meta.zone.timestamps.created,
updated: now
}
};
await ZONES.update(updated);
return Response.json(updated, {
status: 200
});
} catch (err) {
return Response.json({
error: {
message: (err as Error)?.message ?? 'Unknown error due to invalid data.',
cause: (err as Error)?.cause ?? 'invalid_data'
}
}, {
status: 400
});
}
}
// DELETE /api/zones/:id - Delete zone
PRECHECKS.DELETE = [
get_session,
get_user,
require_user,
async (_req: Request, meta: Record<string, any>): Promise<Response | undefined> => {
const zone_id: string = meta.params?.zone_id?.toLowerCase().trim() ?? '';
// lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same"
const zone: ZONE | null = zone_id.length === 49 ? await ZONES.get(zone_id) : null;
if (!zone) {
return CANNED_RESPONSES.not_found();
}
meta.zone = zone;
const user_has_write_for_zone = zone.permissions.write.includes(meta.user.id);
if (!user_has_write_for_zone) {
return CANNED_RESPONSES.permission_denied();
}
}
];
export async function DELETE(_req: Request, meta: Record<string, any>): Promise<Response> {
await ZONES.delete(meta.zone);
return Response.json({
deleted: true
}, {
status: 200
});
}

View file

@ -0,0 +1,28 @@
# /api/zones
Interact with zones.
## POST /api/zones
Create a new zone.
```
export type ZONE = {
id: string; // unique id for this zone
name: string; // the name of the zone (max 128 characters)
icon_url?: string; // optional url for a zone icon
topic?: string; // optional zone topic
tags: string[]; // a list of tags for the zone
meta: Record<string, any>;
limits: {
users: number;
user_messages_per_minute: number;
};
creator_id: string; // user_id of the zone creator
emojis: Record<string, string>; // either: string: emoji eg: { 'rofl: 🤣, ... } or { 'rofl': 🤣, 'blap': 'https://somewhere.someplace/image.jpg' }
};
```
## GET /api/zones
Get zones.

117
public/api/zones/index.ts Normal file
View file

@ -0,0 +1,117 @@
import lurid from '@andyburke/lurid';
import parse_body from '../../../utils/bodyparser.ts';
import { get_session, get_user, require_user } from '../../../utils/prechecks.ts';
import * as CANNED_RESPONSES from '../../../utils/canned_responses.ts';
import { PRECHECK_TABLE } from '../../../utils/prechecks.ts';
import { ZONE, ZONES } from '../../../models/zone.ts';
import { WALK_ENTRY } from '@andyburke/fsdb';
export const PRECHECKS: PRECHECK_TABLE = {};
// GET /api/zones - get zones
PRECHECKS.GET = [get_session, get_user, require_user, (_req: Request, meta: Record<string, any>): Response | undefined => {
const can_read_zones = meta.user.permissions.includes('zones.read');
if (!can_read_zones) {
return CANNED_RESPONSES.permission_denied();
}
}];
export async function GET(_req: Request, meta: Record<string, any>): Promise<Response> {
const limit = Math.min(parseInt(meta.query.limit ?? '100'), 100);
const zones = (await ZONES.all({
limit,
filter: (entry: WALK_ENTRY<ZONE>) => {
// we push our event collections into the zones, and fsdb
// doesn't yet filter that out in its all() logic
return entry.path.indexOf('/events/') === -1;
}
})).map((zone_entry) => zone_entry.load());
return Response.json(zones, {
status: 200
});
}
// POST /api/zones - Create a zone
PRECHECKS.POST = [get_session, get_user, require_user, (_req: Request, meta: Record<string, any>): Response | undefined => {
const can_create_zones = meta.user.permissions.includes('zones.create');
if (!can_create_zones) {
return CANNED_RESPONSES.permission_denied();
}
}];
export async function POST(req: Request, meta: Record<string, any>): Promise<Response> {
try {
const now = new Date().toISOString();
const body = await parse_body(req);
if (typeof body.name !== 'string' || body.name.length === 0) {
return Response.json({
error: {
cause: 'missing_zone_name',
message: 'You must specify a unique name for a zone.'
}
}, {
status: 400
});
}
if (body.name.length > 64) {
return Response.json({
error: {
cause: 'invalid_zone_name',
message: 'zone names must be 64 characters or fewer.'
}
}, {
status: 400
});
}
const normalized_name = body.name.toLowerCase();
const existing_zone = (await ZONES.find({
name: normalized_name
})).shift();
if (existing_zone) {
return Response.json({
error: {
cause: 'zone_name_conflict',
message: 'There is already a zone with this name.'
}
}, {
status: 400
});
}
const zone: ZONE = {
...body,
id: lurid(),
creator_id: meta.user.id,
permissions: {
read: (body.permissions?.read ?? []),
write: (body.permissions?.write ?? [meta.user.id]),
read_events: (body.permissions?.read_events ?? []),
write_events: (body.permissions?.write_events ?? [])
},
timestamps: {
created: now,
updated: now,
archived: undefined
}
};
await ZONES.create(zone);
return Response.json(zone, {
status: 201
});
} catch (error) {
return Response.json({
error: {
message: (error as Error).message ?? 'Unknown Error!',
cause: (error as Error).cause ?? 'unknown'
}
}, { status: 500 });
}
}