mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 15:09:18 -07:00
[events] account events endpoint
This commit is contained in:
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
Accessibility,
|
||||
glyphLength,
|
||||
MAX_EVENT_DESCRIPTION_LENGTH,
|
||||
MAX_EVENT_DURATION_MS,
|
||||
@@ -953,6 +954,72 @@ export async function getEventsByCreator(
|
||||
return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry of a profile's `Responses` — the event beside the player's RSVP to it. The
|
||||
* event is the 17-key BASE shape (the client's v1 PlayerEvent), not the stored record.
|
||||
*/
|
||||
export interface PlayerEventWithResponse {
|
||||
PlayerEvent: PlayerEventBase
|
||||
PlayerEventResponse: PlayerEventResponse
|
||||
}
|
||||
|
||||
/** What anyone may see of a player's events — `GET /api/playerevents/v1/all/{playerId}`. */
|
||||
export interface PlayerPublicEvents {
|
||||
Created: PlayerEventBase[]
|
||||
Responses: PlayerEventWithResponse[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A player's PUBLIC events, as their profile shows them to anyone: the ones they created,
|
||||
* and the ones they are attending — answered Going; a maybe, a decline and an unanswered
|
||||
* invitation are nobody else's business — each soonest first. An event's creator holds a
|
||||
* Going row for it, so their own events appear under both keys.
|
||||
*
|
||||
* Only `Accessibility` Public events are served, and FINISHED ones are left out, as the
|
||||
* browse feed and the room shelf do: this is what the player is going to, not a history.
|
||||
*/
|
||||
export async function getPublicEventsByPlayer(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
now = Date.now()
|
||||
): Promise<PlayerPublicEvents> {
|
||||
const at = eventTime(now)
|
||||
const [created, responses] = await db.batch<EventRow & Partial<EventAttendeeRow>>([
|
||||
db
|
||||
.prepare(
|
||||
`SELECT data FROM event
|
||||
WHERE creator_player_id = ?1 AND end_time >= ?2
|
||||
AND json_extract(data, '$.Accessibility') = ?3`
|
||||
)
|
||||
.bind(playerId, at, Accessibility.Public),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT e.data AS data, a.rowid AS id, a.event_id, a.player_id, a.status, a.responded_at
|
||||
FROM event_attendee a
|
||||
JOIN event e ON e.id = a.event_id
|
||||
WHERE a.player_id = ?1 AND a.status = ?2 AND e.end_time >= ?3
|
||||
AND json_extract(e.data, '$.Accessibility') = ?4`
|
||||
)
|
||||
.bind(playerId, EVENT_RESPONSE.going, at, Accessibility.Public),
|
||||
])
|
||||
return {
|
||||
Created: created.results
|
||||
.map((r) => JSON.parse(r.data) as PlayerEvent)
|
||||
.sort(bySoonest)
|
||||
.map(toEventBase),
|
||||
Responses: responses.results
|
||||
.map((r) => ({
|
||||
event: JSON.parse(r.data) as PlayerEvent,
|
||||
response: toEventResponse(r as EventAttendeeRow),
|
||||
}))
|
||||
.sort((a, b) => bySoonest(a.event, b.event))
|
||||
.map(({ event, response }) => ({
|
||||
PlayerEvent: toEventBase(event),
|
||||
PlayerEventResponse: response,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The events belonging to a set of clubs — the events shelf on a club's page, soonest
|
||||
* first. Selected on the indexed club_id column. An empty id list is an empty shelf
|
||||
|
||||
@@ -1347,6 +1347,17 @@ export const PlayerEventsAll = z.object({
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET /api/playerevents/v1/all/{playerId}` — a player's public events. Both keys carry
|
||||
* the 17-key base event; a `Responses` entry pairs it with the player's RSVP.
|
||||
*/
|
||||
export const PlayerPublicEvents = z.object({
|
||||
Created: z.array(PlayerEventBaseDto).describe('Public events the player created'),
|
||||
Responses: z
|
||||
.array(z.object({ PlayerEvent: PlayerEventBaseDto, PlayerEventResponse: PlayerEventResponseDto }))
|
||||
.describe('Public events the player is attending, each beside their Going RSVP'),
|
||||
})
|
||||
|
||||
/** `GET /api/playerevents/v1/club/:clubId` — the paged single-club event feed. */
|
||||
export const PlayerEventsPage = z.object({
|
||||
ContinuationToken: z.string().describe('Empty = no next page'),
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
getEventTags,
|
||||
getGoingPlayerIds,
|
||||
getLiveEvents,
|
||||
getPublicEventsByPlayer,
|
||||
inviteToEvent,
|
||||
isEventResponseType,
|
||||
parseEventBody,
|
||||
@@ -64,6 +65,7 @@ import {
|
||||
PlayerEventsPage,
|
||||
PlayerEventTagsRequest,
|
||||
PlayerEventTimeRequest,
|
||||
PlayerPublicEvents,
|
||||
stringQuery,
|
||||
SuccessErrorEnvelope,
|
||||
TagFilters,
|
||||
@@ -447,6 +449,31 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// Another player's events, as their profile shows them: PUBLIC events only, and under
|
||||
// `Responses` only the ones they are attending (Going). Public. Unlike the caller's own
|
||||
// `/all` above, both keys carry the client's 17-key base event, and a `Responses` entry
|
||||
// is a PAIR — the event beside the RSVP record.
|
||||
.get(
|
||||
'/api/playerevents/v1/all/:playerId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'A player’s public events',
|
||||
description:
|
||||
'The events on a player’s profile: the PUBLIC ones they created (`Created`) and ' +
|
||||
'the PUBLIC ones they are attending (`Responses`), each soonest first. Public (no ' +
|
||||
'auth); finished events are left out, and a player with none — or an unknown id — ' +
|
||||
'gets two empty lists.\n\n' +
|
||||
'Events are the client’s 17-key BASE event, as on the browse feed. A `Responses` ' +
|
||||
'entry is a pair, `{ PlayerEvent, PlayerEventResponse }`, and only Going RSVPs ' +
|
||||
'(`Type` 0) are listed — a maybe, a decline or an unanswered invitation is not ' +
|
||||
'attending. A creator is Going to their own event, so it appears under both keys.',
|
||||
parameters: [idParam('playerId', 'Player id')],
|
||||
responses: { 200: json(PlayerPublicEvents, 'The player’s public events') },
|
||||
}),
|
||||
async (c) =>
|
||||
c.json(await getPublicEventsByPlayer(c.env.DB, Number.parseInt(c.req.param('playerId'), 10)))
|
||||
)
|
||||
|
||||
// The tag filter chips on the player-events browse screen. Static: these are the
|
||||
// categories the client offers when creating an event, so the list doesn't depend on
|
||||
// what's stored. `TrendingFilters` is null even in the reference — it needs
|
||||
|
||||
@@ -8969,6 +8969,77 @@ describe('player events', () => {
|
||||
expect(theirs.Created.map((e) => e.PlayerEventId)).toEqual([liveEvent.PlayerEventId])
|
||||
})
|
||||
|
||||
test('GET /api/playerevents/v1/all/:playerId serves a player’s PUBLIC events and Going RSVPs', async () => {
|
||||
const respond = (id: number, type: number, sub: string): Promise<Response> =>
|
||||
post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: type }, sub)
|
||||
interface Profile {
|
||||
Created: PlayerEvent[]
|
||||
Responses: Array<{
|
||||
PlayerEvent: Record<string, unknown>
|
||||
PlayerEventResponse: Record<string, unknown>
|
||||
}>
|
||||
}
|
||||
const profile = async (playerId: number | string): Promise<Profile> => {
|
||||
// No token: the read is public.
|
||||
const res = await get(`/api/playerevents/v1/all/${playerId}`)
|
||||
expect(res.status).toBe(200)
|
||||
return (await res.json()) as Profile
|
||||
}
|
||||
|
||||
// 610 hosts a public event, a private one and a finished one; 611 is Going to the
|
||||
// first two, Interested in a third public one, and went to the finished one.
|
||||
const open = await create({ RoomId: 3, Name: 'Profile Open', StartTime: at(2 * HOUR) }, '610')
|
||||
const closed = await create(
|
||||
{ RoomId: 3, Name: 'Profile Closed', StartTime: at(3 * HOUR), Accessibility: 0 },
|
||||
'610'
|
||||
)
|
||||
const maybe = await create({ RoomId: 3, Name: 'Profile Maybe', StartTime: at(HOUR) }, '610')
|
||||
const over = await create(
|
||||
{ RoomId: 3, Name: 'Profile Over', StartTime: at(-3 * HOUR), EndTime: at(-2 * HOUR) },
|
||||
'610'
|
||||
)
|
||||
await respond(open.PlayerEventId, 0, '611')
|
||||
await respond(closed.PlayerEventId, 0, '611')
|
||||
await respond(maybe.PlayerEventId, 1, '611')
|
||||
await respond(over.PlayerEventId, 0, '611')
|
||||
|
||||
// The attendee: exactly the public event they are Going to, as the pair.
|
||||
const guest = await profile(611)
|
||||
expect(guest.Created).toEqual([])
|
||||
expect(guest.Responses).toHaveLength(1)
|
||||
const [entry] = guest.Responses
|
||||
expect(Object.keys(entry).sort()).toEqual(['PlayerEvent', 'PlayerEventResponse'])
|
||||
expect(entry.PlayerEventResponse).toEqual({
|
||||
PlayerEventResponseId: expect.any(Number),
|
||||
PlayerEventId: open.PlayerEventId,
|
||||
PlayerId: 611,
|
||||
CreatedAt: expect.any(String),
|
||||
Type: 0,
|
||||
})
|
||||
// The 17-key base event: no `State`, `ImageName` a string, the broadcast key present.
|
||||
expect(Object.keys(entry.PlayerEvent)).toHaveLength(17)
|
||||
expect(entry.PlayerEvent).toMatchObject({
|
||||
PlayerEventId: open.PlayerEventId,
|
||||
Name: 'Profile Open',
|
||||
AttendeeCount: 2,
|
||||
Accessibility: 1,
|
||||
ImageName: '',
|
||||
BroadcastingRoomInstanceId: null,
|
||||
})
|
||||
expect(entry.PlayerEvent).not.toHaveProperty('State')
|
||||
|
||||
// The host: their public unfinished events, soonest first, under both keys.
|
||||
const host = await profile(610)
|
||||
const ids = [maybe.PlayerEventId, open.PlayerEventId]
|
||||
expect(host.Created.map((e) => e.PlayerEventId)).toEqual(ids)
|
||||
expect(Object.keys(host.Created[0])).toHaveLength(17)
|
||||
expect(host.Responses.map((r) => r.PlayerEvent.PlayerEventId)).toEqual(ids)
|
||||
|
||||
// Nobody's events, and a non-numeric id doesn't match.
|
||||
expect(await profile(999999)).toEqual({ Created: [], Responses: [] })
|
||||
expect((await get('/api/playerevents/v1/all/abc')).status).toBe(404)
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v1/respond records an RSVP and recounts attendees', async () => {
|
||||
const respond = async (body: unknown, sub = '42'): Promise<Response> =>
|
||||
post('/api/playerevents/v1/respond', body, sub)
|
||||
@@ -9785,6 +9856,7 @@ describe('openapi', () => {
|
||||
'GET /api/playerReputation/v2/bulk',
|
||||
'GET /api/playerevents/v1',
|
||||
'GET /api/playerevents/v1/all',
|
||||
'GET /api/playerevents/v1/all/{playerId}',
|
||||
'GET /api/playerevents/v1/bulk',
|
||||
'GET /api/playerevents/v1/club/{clubId}',
|
||||
'GET /api/playerevents/v1/clubs',
|
||||
|
||||
Reference in New Issue
Block a user