From 208178494a041c978440875a634da66fe78a0775 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Thu, 22 Oct 2026 12:46:17 -0400 Subject: [PATCH] [clubs] announce delete --- apps/clubs/src/clubs.app.ts | 54 +++++++++++++++++++++ apps/clubs/src/openapi.ts | 11 +++++ apps/clubs/src/test/integration/api.test.ts | 54 +++++++++++++++++++++ packages/domain/src/clubs-db.ts | 19 ++++++++ 4 files changed, 138 insertions(+) diff --git a/apps/clubs/src/clubs.app.ts b/apps/clubs/src/clubs.app.ts index 44f943b..31566f3 100644 --- a/apps/clubs/src/clubs.app.ts +++ b/apps/clubs/src/clubs.app.ts @@ -12,6 +12,7 @@ import { createClub, createClubAnnouncement, deleteClub, + deleteClubAnnouncement, getClub, getClubAnnouncements, getClubDetails, @@ -56,6 +57,7 @@ import { ClubMembersEnvelope, ClubSearchResponse, CreateClubRequest, + DeleteAnnouncementResponse, EmptyObject, ErrorEnvelope, form, @@ -643,6 +645,58 @@ const app = new Hono() } ) + // Delete one of a club's announcements. The club's owner (creator) only. Answers + // `{ Success, Error, error_id }` — PascalCase, unlike the rest of this worker. + .delete( + '/announcements/club/:clubId{[0-9]+}/:announcementId{[0-9]+}', + describeRoute({ + tags: ['Announcements'], + summary: 'Delete a club announcement', + description: [ + 'The club’s owner (creator) only. Answers `{ Success, Error, error_id }` —', + 'PascalCase, not the `{ error, success, value }` envelope the rest of this worker uses.', + ].join(' '), + security: AUTHED, + parameters: [ + CLUB_ID_PARAM, + { + name: 'announcementId', + in: 'path', + required: true, + description: 'The announcement’s id', + schema: { type: 'string' }, + }, + ], + responses: { + 200: json(DeleteAnnouncementResponse, 'Deleted'), + 401: UNAUTHORIZED_RESPONSE, + 403: json(DeleteAnnouncementResponse, 'Not the club’s owner'), + 404: json(DeleteAnnouncementResponse, 'No such club or announcement'), + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return c.body(null, 401) + + const clubId = Number.parseInt(c.req.param('clubId'), 10) + const announcementId = Number.parseInt(c.req.param('announcementId'), 10) + const club = await getClub(c.env.DB, clubId) + if (club === null) { + return c.json({ Success: false, Error: 'Club not found.', error_id: null }, 404) + } + + const membership = await getMembership(c.env.DB, clubId, id) + if (membership < ClubMembershipType.Creator) { + return c.json({ Success: false, Error: 'Insufficient permissions.', error_id: null }, 403) + } + + if (!(await deleteClubAnnouncement(c.env.DB, clubId, announcementId))) { + return c.json({ Success: false, Error: 'Announcement not found.', error_id: null }, 404) + } + return c.json({ Success: true, Error: null, error_id: null }) + } + ) + // The clubs the player is a member of (GetMyMembershipClubs). Reads the caller's // memberships from `club_member`. A caller with no valid token has no clubs, so // this answers an empty list rather than 401ing — the client shows the "my clubs" diff --git a/apps/clubs/src/openapi.ts b/apps/clubs/src/openapi.ts index c00ad95..b9b080e 100644 --- a/apps/clubs/src/openapi.ts +++ b/apps/clubs/src/openapi.ts @@ -236,6 +236,17 @@ export const AnnouncementIdEnvelope = z.object({ value: z.int().describe('The new announcement’s id'), }) +/** + * The reply to deleting an announcement (`DELETE /announcements/club/:clubId/:announcementId`). + * PascalCase with a snake_case `error_id` — not the `{ error, success, value }` envelope + * the rest of this worker answers. + */ +export const DeleteAnnouncementResponse = z.object({ + Success: z.boolean(), + Error: z.string().nullable().describe('Null on success; the message shown otherwise'), + error_id: z.null(), +}) + // ---- Other response shapes ------------------------------------------------- /** `GET /club/search` — a page of clubs plus the full match count. */ diff --git a/apps/clubs/src/test/integration/api.test.ts b/apps/clubs/src/test/integration/api.test.ts index 6a912be..4caa1d7 100644 --- a/apps/clubs/src/test/integration/api.test.ts +++ b/apps/clubs/src/test/integration/api.test.ts @@ -1267,6 +1267,59 @@ describe('clubs endpoints', () => { expect(missing.status).toBe(404) }) + test('DELETE /announcements/club/:id/:announcementId deletes one, owner only', async () => { + const create = await exports.default.fetch(`${ORIGIN}/club/create`, { + method: 'POST', + headers: { ...(await bearer('9450')), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'name=Retractions', + }) + const clubId = ((await create.json()) as { value: { ClubId: number } }).value.ClubId + const postOne = async (title: string): Promise => { + const res = await exports.default.fetch(`${ORIGIN}/announcements/club/${clubId}`, { + method: 'POST', + headers: { ...(await bearer('9450')), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `title=${title}`, + }) + return ((await res.json()) as { value: number }).value + } + const keepId = await postOne('Keep') + const dropId = await postOne('Drop') + const del = async (path: string, sub: string | null = '9450'): Promise => + exports.default.fetch(`${ORIGIN}/announcements/club/${path}`, { + method: 'DELETE', + headers: sub === null ? {} : await bearer(sub), + }) + const ids = async (): Promise => { + const res = await exports.default.fetch(`${ORIGIN}/announcements/club/${clubId}`) + const body = (await res.json()) as { + value: { Announcements: Array<{ AnnouncementId: number }> } + } + return body.value.Announcements.map((a) => a.AnnouncementId) + } + + // Signed out is a 401; a non-member is refused; neither touches the board. + expect((await del(`${clubId}/${dropId}`, null)).status).toBe(401) + const refused = await del(`${clubId}/${dropId}`, '9451') + expect(refused.status).toBe(403) + expect(await refused.json()).toEqual({ + Success: false, + Error: 'Insufficient permissions.', + error_id: null, + }) + expect(await ids()).toEqual([dropId, keepId]) + + // The owner deletes it; the other stays. + const ok = await del(`${clubId}/${dropId}`) + expect(ok.status).toBe(200) + expect(await ok.json()).toEqual({ Success: true, Error: null, error_id: null }) + expect(await ids()).toEqual([keepId]) + + // Gone now, so a repeat 404s; so does an unknown club, or an id from another club. + expect((await del(`${clubId}/${dropId}`)).status).toBe(404) + expect((await del(`99999/${keepId}`)).status).toBe(404) + expect(await ids()).toEqual([keepId]) + }) + test('PUT /club/:id/minlevel sets the join level, co-owner only', async () => { type Details = { error: string; success: boolean; value: { Club: { MinLevel: number } } } const create = await exports.default.fetch(`${ORIGIN}/club/create`, { @@ -1700,6 +1753,7 @@ describe('clubs endpoints', () => { ) ) expect([...documented].sort()).toEqual([ + 'DELETE /announcements/club/{clubId}/{announcementId}', 'DELETE /club/home/me', 'DELETE /club/{clubId}', 'DELETE /club/{clubId}/additionalimage/{index}', diff --git a/packages/domain/src/clubs-db.ts b/packages/domain/src/clubs-db.ts index 84db877..4c8552b 100644 --- a/packages/domain/src/clubs-db.ts +++ b/packages/domain/src/clubs-db.ts @@ -452,6 +452,25 @@ export async function createClubAnnouncement( return row?.announcement_id ?? 0 } +/** + * Delete one of a club's announcements. Scoped to the club, so an id belonging to + * another club is left alone. Returns whether anything was deleted. + */ +export async function deleteClubAnnouncement( + db: D1Database, + clubId: number, + announcementId: number +): Promise { + const row = await db + .prepare( + `DELETE FROM club_announcement WHERE club_id = ?1 AND announcement_id = ?2 + RETURNING announcement_id` + ) + .bind(clubId, announcementId) + .first<{ announcement_id: number }>() + return row !== null +} + // ---- Subscriptions --------------------------------------------------------- // // Rec Room had no per-player "follow": a creator's subscribers are the members of a