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,
|
createClub,
|
||||||
createClubAnnouncement,
|
createClubAnnouncement,
|
||||||
deleteClub,
|
deleteClub,
|
||||||
|
deleteClubAnnouncement,
|
||||||
getClub,
|
getClub,
|
||||||
getClubAnnouncements,
|
getClubAnnouncements,
|
||||||
getClubDetails,
|
getClubDetails,
|
||||||
@@ -56,6 +57,7 @@ import {
|
|||||||
ClubMembersEnvelope,
|
ClubMembersEnvelope,
|
||||||
ClubSearchResponse,
|
ClubSearchResponse,
|
||||||
CreateClubRequest,
|
CreateClubRequest,
|
||||||
|
DeleteAnnouncementResponse,
|
||||||
EmptyObject,
|
EmptyObject,
|
||||||
ErrorEnvelope,
|
ErrorEnvelope,
|
||||||
form,
|
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
|
// 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
|
// 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"
|
// 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'),
|
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 -------------------------------------------------
|
// ---- Other response shapes -------------------------------------------------
|
||||||
|
|
||||||
/** `GET /club/search` — a page of clubs plus the full match count. */
|
/** `GET /club/search` — a page of clubs plus the full match count. */
|
||||||
|
|||||||
@@ -1267,6 +1267,59 @@ describe('clubs endpoints', () => {
|
|||||||
expect(missing.status).toBe(404)
|
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 () => {
|
test('PUT /club/:id/minlevel sets the join level, co-owner only', async () => {
|
||||||
type Details = { error: string; success: boolean; value: { Club: { MinLevel: number } } }
|
type Details = { error: string; success: boolean; value: { Club: { MinLevel: number } } }
|
||||||
const create = await exports.default.fetch(`${ORIGIN}/club/create`, {
|
const create = await exports.default.fetch(`${ORIGIN}/club/create`, {
|
||||||
@@ -1700,6 +1753,7 @@ describe('clubs endpoints', () => {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
expect([...documented].sort()).toEqual([
|
expect([...documented].sort()).toEqual([
|
||||||
|
'DELETE /announcements/club/{clubId}/{announcementId}',
|
||||||
'DELETE /club/home/me',
|
'DELETE /club/home/me',
|
||||||
'DELETE /club/{clubId}',
|
'DELETE /club/{clubId}',
|
||||||
'DELETE /club/{clubId}/additionalimage/{index}',
|
'DELETE /club/{clubId}/additionalimage/{index}',
|
||||||
|
|||||||
@@ -452,6 +452,25 @@ export async function createClubAnnouncement(
|
|||||||
return row?.announcement_id ?? 0
|
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 ---------------------------------------------------------
|
// ---- Subscriptions ---------------------------------------------------------
|
||||||
//
|
//
|
||||||
// Rec Room had no per-player "follow": a creator's subscribers are the members of a
|
// Rec Room had no per-player "follow": a creator's subscribers are the members of a
|
||||||
|
|||||||
Reference in New Issue
Block a user