mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[clubs] announce delete
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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<boolean> {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user