[clubs] announce delete

This commit is contained in:
Devin Zuczek
2026-10-22 12:46:17 -04:00
parent 2222a545ff
commit 208178494a
4 changed files with 138 additions and 0 deletions
+54
View File
@@ -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<App>()
}
)
// 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"
+11
View File
@@ -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. */
@@ -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<number> => {
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<Response> =>
exports.default.fetch(`${ORIGIN}/announcements/club/${path}`, {
method: 'DELETE',
headers: sub === null ? {} : await bearer(sub),
})
const ids = async (): Promise<number[]> => {
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}',