mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[techdebt] cleanup duplicate schema definitions
This commit is contained in:
@@ -1,421 +0,0 @@
|
|||||||
/**
|
|
||||||
* Image-metadata storage on the shared `recflare` D1 database. Each image is a
|
|
||||||
* single JSON blob in the `data` column; queryable fields (Id, ImageName,
|
|
||||||
* PlayerId, RoomId) are SQLite generated (virtual) columns extracted from that
|
|
||||||
* JSON — the same JSON-blob pattern the rooms/accounts tables use.
|
|
||||||
*
|
|
||||||
* Mirror of `apps/img/src/images-db.ts` — the `img` worker owns the schema and
|
|
||||||
* migration; this worker (which handles uploads + reads) keeps a copy in sync.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Schema DDL (mirror of migrations/0001_image.sql, sans any seed rows). */
|
|
||||||
export const SCHEMA_DDL: string[] = [
|
|
||||||
`CREATE TABLE IF NOT EXISTS image (
|
|
||||||
data TEXT NOT NULL,
|
|
||||||
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Id')) VIRTUAL,
|
|
||||||
image_name TEXT GENERATED ALWAYS AS (json_extract(data, '$.ImageName')) VIRTUAL,
|
|
||||||
player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.PlayerId')) VIRTUAL,
|
|
||||||
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL
|
|
||||||
)`,
|
|
||||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_image_id ON image (id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_image_image_name ON image (image_name)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_image_player_id ON image (player_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_image_room_id ON image (room_id)`,
|
|
||||||
// A player's interaction with a saved image — one row per (player, image). Only
|
|
||||||
// `cheered` for now; named generically so other per-user interactions (e.g.
|
|
||||||
// favorited) can be added as columns. This worker writes it (cheer endpoints) and
|
|
||||||
// keeps the image's denormalized `CheerCount` in sync from it. Schema owned by the
|
|
||||||
// `img` worker (migrations/0002_image_interaction.sql) — keep in sync.
|
|
||||||
`CREATE TABLE IF NOT EXISTS image_interaction (
|
|
||||||
player_id INTEGER NOT NULL,
|
|
||||||
saved_image_id INTEGER NOT NULL,
|
|
||||||
cheered INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT,
|
|
||||||
PRIMARY KEY (player_id, saved_image_id)
|
|
||||||
)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_image_interaction_image ON image_interaction (saved_image_id)`,
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Saved-image categories from the reference's `SavedImageType` enum — the value of a
|
|
||||||
* stored image's `Type` (and the client's `imgMeta.savedImageType` on upload). Lives
|
|
||||||
* here in the image data layer so both the upload route and the slideshow query share
|
|
||||||
* one definition.
|
|
||||||
*/
|
|
||||||
export const SavedImageType = {
|
|
||||||
None: 0,
|
|
||||||
ShareCamera: 1,
|
|
||||||
OutfitThumbnail: 2,
|
|
||||||
RoomThumbnail: 3,
|
|
||||||
ProfileThumbnail: 4,
|
|
||||||
InventionThumbnail: 5,
|
|
||||||
} as const
|
|
||||||
|
|
||||||
/** A stored image record (the client-facing SavedImage shape). */
|
|
||||||
export interface SavedImage {
|
|
||||||
Id: number
|
|
||||||
/** A {@link SavedImageType} value. */
|
|
||||||
Type: number
|
|
||||||
Accessibility: number
|
|
||||||
AccessibilityLocked: boolean
|
|
||||||
ImageName: string
|
|
||||||
Description: string | null
|
|
||||||
PlayerId: number
|
|
||||||
TaggedPlayerIds: number[]
|
|
||||||
RoomId: number | null
|
|
||||||
PlayerEventId: number | null
|
|
||||||
CreatedAt: string
|
|
||||||
CheerCount: number
|
|
||||||
CommentCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImageRow {
|
|
||||||
data: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fields supplied at upload time (from `imgMeta`); everything else defaults. */
|
|
||||||
export interface NewImage {
|
|
||||||
imageName: string
|
|
||||||
playerId: number
|
|
||||||
type?: number
|
|
||||||
accessibility?: number
|
|
||||||
roomId?: number | null
|
|
||||||
description?: string | null
|
|
||||||
taggedPlayerIds?: number[]
|
|
||||||
playerEventId?: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Insert a new image record for an upload, returning the stored row. */
|
|
||||||
export async function createImage(db: D1Database, input: NewImage): Promise<SavedImage> {
|
|
||||||
// Sequential id: one past the current max (the table starts empty).
|
|
||||||
const row = await db
|
|
||||||
.prepare('SELECT COALESCE(MAX(id), 0) + 1 AS next FROM image')
|
|
||||||
.first<{ next: number }>()
|
|
||||||
const image: SavedImage = {
|
|
||||||
Id: row?.next ?? 1,
|
|
||||||
Type: input.type ?? 1,
|
|
||||||
Accessibility: input.accessibility ?? 1,
|
|
||||||
AccessibilityLocked: false,
|
|
||||||
ImageName: input.imageName,
|
|
||||||
Description: input.description ?? null,
|
|
||||||
PlayerId: input.playerId,
|
|
||||||
TaggedPlayerIds: input.taggedPlayerIds ?? [],
|
|
||||||
RoomId: input.roomId ?? null,
|
|
||||||
PlayerEventId: input.playerEventId ?? null,
|
|
||||||
CreatedAt: new Date().toISOString(),
|
|
||||||
CheerCount: 0,
|
|
||||||
CommentCount: 0,
|
|
||||||
}
|
|
||||||
await db.prepare('INSERT INTO image (data) VALUES (?1)').bind(JSON.stringify(image)).run()
|
|
||||||
return image
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recompute an image's `CheerCount` from the `image_interaction` rows and write it
|
|
||||||
* back into the blob (nothing reads a generated column for it, but the client-facing
|
|
||||||
* blob must stay accurate). CAST to INTEGER: D1 binds a JS number as a SQLite REAL,
|
|
||||||
* which json_set would otherwise store as `"CheerCount":3.0`. Returns the fresh count.
|
|
||||||
*/
|
|
||||||
async function syncImageCheerCount(db: D1Database, savedImageId: number): Promise<number> {
|
|
||||||
const row = await db
|
|
||||||
.prepare(
|
|
||||||
'SELECT COUNT(*) AS n FROM image_interaction WHERE saved_image_id = ?1 AND cheered = 1'
|
|
||||||
)
|
|
||||||
.bind(savedImageId)
|
|
||||||
.first<{ n: number }>()
|
|
||||||
const count = row?.n ?? 0
|
|
||||||
await db
|
|
||||||
.prepare(
|
|
||||||
"UPDATE image SET data = json_set(data, '$.CheerCount', CAST(?2 AS INTEGER)) WHERE id = ?1"
|
|
||||||
)
|
|
||||||
.bind(savedImageId, count)
|
|
||||||
.run()
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set (or clear) a player's cheer on a saved image — upserts the one row per
|
|
||||||
* (player, image) — then resyncs the image's `CheerCount`. Idempotent: re-cheering
|
|
||||||
* an already-cheered image is a no-op on the count.
|
|
||||||
*/
|
|
||||||
export async function setImageCheer(
|
|
||||||
db: D1Database,
|
|
||||||
playerId: number,
|
|
||||||
savedImageId: number,
|
|
||||||
cheer: boolean
|
|
||||||
): Promise<void> {
|
|
||||||
await db
|
|
||||||
.prepare(
|
|
||||||
`INSERT INTO image_interaction (player_id, saved_image_id, cheered, created_at)
|
|
||||||
VALUES (?1, ?2, ?3, ?4)
|
|
||||||
ON CONFLICT(player_id, saved_image_id) DO UPDATE SET cheered = ?3`
|
|
||||||
)
|
|
||||||
.bind(playerId, savedImageId, cheer ? 1 : 0, new Date().toISOString())
|
|
||||||
.run()
|
|
||||||
await syncImageCheerCount(db, savedImageId)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Which of the given saved-image ids the player has cheered — the set of cheered
|
|
||||||
* ids (a subset of `ids`). Backs the bulk `cheered` lookup. Empty input → empty set.
|
|
||||||
*/
|
|
||||||
export async function getCheeredImageIds(
|
|
||||||
db: D1Database,
|
|
||||||
playerId: number,
|
|
||||||
ids: number[]
|
|
||||||
): Promise<Set<number>> {
|
|
||||||
if (ids.length === 0) return new Set()
|
|
||||||
const inList = ids.map((_, i) => `?${i + 2}`).join(',')
|
|
||||||
const { results } = await db
|
|
||||||
.prepare(
|
|
||||||
`SELECT saved_image_id AS id FROM image_interaction
|
|
||||||
WHERE player_id = ?1 AND cheered = 1 AND saved_image_id IN (${inList})`
|
|
||||||
)
|
|
||||||
.bind(playerId, ...ids)
|
|
||||||
.all<{ id: number }>()
|
|
||||||
return new Set(results.map((r) => r.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Look up an image record by its ImageName (the R2 key / filename), or null. */
|
|
||||||
export async function getImageByName(db: D1Database, name: string): Promise<SavedImage | null> {
|
|
||||||
const row = await db
|
|
||||||
.prepare('SELECT data FROM image WHERE image_name = ?1')
|
|
||||||
.bind(name)
|
|
||||||
.first<ImageRow>()
|
|
||||||
return row ? (JSON.parse(row.data) as SavedImage) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete an image's metadata row plus any per-player interactions (cheers) recorded
|
|
||||||
* against it, in one batch — the row keyed by ImageName (the R2 key), its interactions
|
|
||||||
* by the image's `Id`. Authorization and removing the object from R2 are the caller's
|
|
||||||
* responsibility (see the deletesaved route).
|
|
||||||
*/
|
|
||||||
export async function deleteImage(db: D1Database, image: SavedImage): Promise<void> {
|
|
||||||
await db.batch([
|
|
||||||
db.prepare('DELETE FROM image WHERE image_name = ?1').bind(image.ImageName),
|
|
||||||
db.prepare('DELETE FROM image_interaction WHERE saved_image_id = ?1').bind(image.Id),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The public images taken in a room, for the room's photo feed. Only publicly
|
|
||||||
* accessible images (Accessibility === 1) are returned. `filter` narrows by
|
|
||||||
* `SavedImageType` (0 = all types); `sort` orders the feed — `1` puts the most
|
|
||||||
* cheered first (ties broken by newest), anything else is newest-first. Paginated
|
|
||||||
* via skip/take; returns a bare array of SavedImage. The per-room set is small, so
|
|
||||||
* the room_id index does the lookup and filtering/sorting happens in memory.
|
|
||||||
*
|
|
||||||
* NOTE: the exact `sort`/`filter` enum values are best guesses — the client sends
|
|
||||||
* `sort=1&filter=1`, and this treats them as most-cheered / ShareCamera.
|
|
||||||
*/
|
|
||||||
export async function getImagesByRoom(
|
|
||||||
db: D1Database,
|
|
||||||
roomId: number,
|
|
||||||
sort: number,
|
|
||||||
filter: number,
|
|
||||||
skip: number,
|
|
||||||
take: number
|
|
||||||
): Promise<SavedImage[]> {
|
|
||||||
const { results } = await db
|
|
||||||
.prepare('SELECT data FROM image WHERE room_id = ?1')
|
|
||||||
.bind(roomId)
|
|
||||||
.all<ImageRow>()
|
|
||||||
let images = results
|
|
||||||
.map((r) => JSON.parse(r.data) as SavedImage)
|
|
||||||
.filter((img) => img.Accessibility === 1)
|
|
||||||
|
|
||||||
if (filter > 0) images = images.filter((img) => img.Type === filter)
|
|
||||||
|
|
||||||
images.sort(sort === 1 ? (a, b) => b.CheerCount - a.CheerCount || newestFirst(a, b) : newestFirst)
|
|
||||||
|
|
||||||
return images.slice(skip, skip + take)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Newest-first order: most recent CreatedAt, ties broken by higher Id. */
|
|
||||||
const newestFirst = (a: SavedImage, b: SavedImage) =>
|
|
||||||
b.CreatedAt.localeCompare(a.CreatedAt) || b.Id - a.Id
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The public images a player has taken — their photo list, newest first.
|
|
||||||
* Paginated via skip/take; returns a bare array of SavedImage. Uses the
|
|
||||||
* player_id index; the per-player set is small, so filtering/sorting is in memory.
|
|
||||||
*/
|
|
||||||
export async function getImagesByPlayer(
|
|
||||||
db: D1Database,
|
|
||||||
playerId: number,
|
|
||||||
sort: number,
|
|
||||||
skip: number,
|
|
||||||
take: number
|
|
||||||
): Promise<SavedImage[]> {
|
|
||||||
const { results } = await db
|
|
||||||
.prepare('SELECT data FROM image WHERE player_id = ?1')
|
|
||||||
.bind(playerId)
|
|
||||||
.all<ImageRow>()
|
|
||||||
return results
|
|
||||||
.map((r) => JSON.parse(r.data) as SavedImage)
|
|
||||||
.filter((img) => img.Accessibility === 1)
|
|
||||||
.sort(sort === 1 ? (a, b) => b.CheerCount - a.CheerCount || newestFirst(a, b) : newestFirst)
|
|
||||||
.slice(skip, skip + take)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The client-facing projection of a saved image for the player photo lists (the
|
|
||||||
* reference's `ImagesPlayer`). Same data as the stored record, but the id and type
|
|
||||||
* are renamed — `Id` → `SavedImageId`, `Type` → `SavedImageType` — and the tagged
|
|
||||||
* player ids aren't part of it. The client deserializes into this shape, so a raw
|
|
||||||
* SavedImage leaves it without an image id and its thumbnails come up blank.
|
|
||||||
*/
|
|
||||||
export interface ImagesPlayer {
|
|
||||||
Accessibility: number
|
|
||||||
AccessibilityLocked: boolean
|
|
||||||
CheerCount: number
|
|
||||||
CommentCount: number
|
|
||||||
CreatedAt: string
|
|
||||||
Description: string | null
|
|
||||||
ImageName: string
|
|
||||||
PlayerEventId: number | null
|
|
||||||
PlayerId: number
|
|
||||||
RoomId: number | null
|
|
||||||
SavedImageId: number
|
|
||||||
SavedImageType: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Project a stored image to the client's ImagesPlayer shape. */
|
|
||||||
export function toImagesPlayer(img: SavedImage): ImagesPlayer {
|
|
||||||
return {
|
|
||||||
Accessibility: img.Accessibility,
|
|
||||||
AccessibilityLocked: img.AccessibilityLocked,
|
|
||||||
CheerCount: img.CheerCount,
|
|
||||||
CommentCount: img.CommentCount,
|
|
||||||
CreatedAt: img.CreatedAt,
|
|
||||||
Description: img.Description,
|
|
||||||
ImageName: img.ImageName,
|
|
||||||
PlayerEventId: img.PlayerEventId,
|
|
||||||
PlayerId: img.PlayerId,
|
|
||||||
RoomId: img.RoomId,
|
|
||||||
SavedImageId: img.Id,
|
|
||||||
SavedImageType: img.Type,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How many recent images the slideshow feed returns when the caller doesn't say. */
|
|
||||||
export const SLIDESHOW_LIMIT = 10
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The most a caller can ask the slideshow feed for. The endpoint is public and
|
|
||||||
* unauthenticated, so the cap is what keeps an arbitrary `take` from turning into a
|
|
||||||
* scan of the whole image table plus the two batched joins behind it.
|
|
||||||
*/
|
|
||||||
export const SLIDESHOW_MAX_LIMIT = 100
|
|
||||||
|
|
||||||
/** The slideshow projection of an image — creator username + room name joined in. */
|
|
||||||
export interface SlideshowImage {
|
|
||||||
SavedImageId: number
|
|
||||||
ImageName: string
|
|
||||||
Username: string
|
|
||||||
RoomName: string | null
|
|
||||||
RoomId: number | null
|
|
||||||
SavedImageType: number
|
|
||||||
PlayerEventId: number | null
|
|
||||||
Accessibility: number
|
|
||||||
PlayerIds: number[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build the `?1,?2,…` placeholder list for an `IN (…)` clause. */
|
|
||||||
const placeholders = (n: number): string =>
|
|
||||||
Array.from({ length: n }, (_, i) => `?${i + 1}`).join(',')
|
|
||||||
|
|
||||||
/** Map account ids → username, resolved from the shared accounts table. */
|
|
||||||
async function getUsernames(db: D1Database, ids: number[]): Promise<Map<number, string>> {
|
|
||||||
if (ids.length === 0) return new Map()
|
|
||||||
const { results } = await db
|
|
||||||
.prepare(
|
|
||||||
`SELECT account_id AS id, json_extract(data, '$.username') AS username
|
|
||||||
FROM account WHERE account_id IN (${placeholders(ids.length)})`
|
|
||||||
)
|
|
||||||
.bind(...ids)
|
|
||||||
.all<{ id: number; username: string }>()
|
|
||||||
return new Map(results.map((r) => [r.id, r.username]))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Map room ids → room name, resolved from the shared rooms table. */
|
|
||||||
async function getRoomNames(db: D1Database, ids: number[]): Promise<Map<number, string>> {
|
|
||||||
if (ids.length === 0) return new Map()
|
|
||||||
const { results } = await db
|
|
||||||
.prepare(
|
|
||||||
`SELECT room_id AS id, json_extract(data, '$.Name') AS name
|
|
||||||
FROM room WHERE room_id IN (${placeholders(ids.length)})`
|
|
||||||
)
|
|
||||||
.bind(...ids)
|
|
||||||
.all<{ id: number; name: string }>()
|
|
||||||
return new Map(results.map((r) => [r.id, r.name]))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The global slideshow feed — the most recent publicly-listable ShareCamera photos
|
|
||||||
* across all rooms (Accessibility 0 or 1, Type 1), newest first, capped at `limit`.
|
|
||||||
* Only ShareCamera images are surfaced (not room/profile/invention thumbnails). Each
|
|
||||||
* row is joined to its creator's username and (if any) its room's name. Returns the
|
|
||||||
* projected SlideshowImage shape. Usernames/room names are resolved in two batched
|
|
||||||
* lookups to avoid an N+1 across the (at most `limit`) images.
|
|
||||||
*/
|
|
||||||
export async function getSlideshowImages(
|
|
||||||
db: D1Database,
|
|
||||||
limit = SLIDESHOW_LIMIT
|
|
||||||
): Promise<SlideshowImage[]> {
|
|
||||||
const { results } = await db
|
|
||||||
.prepare(
|
|
||||||
`SELECT data FROM image
|
|
||||||
WHERE json_extract(data, '$.Accessibility') IN (0, 1)
|
|
||||||
AND json_extract(data, '$.Type') = ?1
|
|
||||||
ORDER BY id DESC LIMIT ?2`
|
|
||||||
)
|
|
||||||
.bind(SavedImageType.ShareCamera, limit)
|
|
||||||
.all<ImageRow>()
|
|
||||||
const images = results.map((r) => JSON.parse(r.data) as SavedImage)
|
|
||||||
|
|
||||||
const roomIds = [...new Set(images.map((i) => i.RoomId).filter((v): v is number => v != null))]
|
|
||||||
const usernames = await getUsernames(db, [...new Set(images.map((i) => i.PlayerId))])
|
|
||||||
const roomNames = await getRoomNames(db, roomIds)
|
|
||||||
|
|
||||||
return images.map((img) => ({
|
|
||||||
SavedImageId: img.Id,
|
|
||||||
ImageName: img.ImageName,
|
|
||||||
// Fall back to the synthesized "Player<id>" name for accounts not in the table.
|
|
||||||
Username: usernames.get(img.PlayerId) ?? `Player${img.PlayerId}`,
|
|
||||||
RoomName: img.RoomId != null ? (roomNames.get(img.RoomId) ?? null) : null,
|
|
||||||
RoomId: img.RoomId,
|
|
||||||
SavedImageType: img.Type,
|
|
||||||
PlayerEventId: img.PlayerEventId,
|
|
||||||
Accessibility: img.Accessibility,
|
|
||||||
PlayerIds: img.TaggedPlayerIds,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A player's photo feed — the public images they took plus the ones they're
|
|
||||||
* tagged in (TaggedPlayerIds). Newest first, paginated via skip/take; returns a
|
|
||||||
* bare array of SavedImage. The tagged-in match uses json_each over the stored
|
|
||||||
* TaggedPlayerIds array (there's no index for it).
|
|
||||||
*/
|
|
||||||
export async function getPlayerFeed(
|
|
||||||
db: D1Database,
|
|
||||||
playerId: number,
|
|
||||||
skip: number,
|
|
||||||
take: number
|
|
||||||
): Promise<SavedImage[]> {
|
|
||||||
const { results } = await db
|
|
||||||
.prepare(
|
|
||||||
`SELECT data FROM image
|
|
||||||
WHERE player_id = ?1
|
|
||||||
OR EXISTS (SELECT 1 FROM json_each(image.data, '$.TaggedPlayerIds') WHERE value = ?1)`
|
|
||||||
)
|
|
||||||
.bind(playerId)
|
|
||||||
.all<ImageRow>()
|
|
||||||
return results
|
|
||||||
.map((r) => JSON.parse(r.data) as SavedImage)
|
|
||||||
.filter((img) => img.Accessibility === 1)
|
|
||||||
.sort(newestFirst)
|
|
||||||
.slice(skip, skip + take)
|
|
||||||
}
|
|
||||||
@@ -505,12 +505,6 @@ export const PlayerEventsPage = z.object({
|
|||||||
Events: JsonArray,
|
Events: JsonArray,
|
||||||
})
|
})
|
||||||
|
|
||||||
/** `POST /api/CampusCard/v1/UpdateAndGetSubscription` — both null (no subs yet). */
|
|
||||||
export const SubscriptionResponse = z.object({
|
|
||||||
subscription: z.null(),
|
|
||||||
platformAccountSubscribedPlayerId: z.null(),
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---- Moderation ------------------------------------------------------------
|
// ---- Moderation ------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,427 +0,0 @@
|
|||||||
/**
|
|
||||||
* Friendship / relationship storage on the shared `recflare` D1 database.
|
|
||||||
*
|
|
||||||
* Unlike the JSON-blob tables in this database (rooms/accounts/image), a
|
|
||||||
* relationship is genuinely columnar, so it gets a normal relational table
|
|
||||||
* (mirroring the Go/GORM `Relationship` model). Exactly ONE row exists per
|
|
||||||
* unordered pair of players: the player who initiated is the `requester`, the
|
|
||||||
* other is the `target`. `relationship_type` is stored from the requester's
|
|
||||||
* point of view; when we project the row for the *target* we flip
|
|
||||||
* Sent↔Received (Friend/None are symmetric).
|
|
||||||
*
|
|
||||||
* The `api` worker owns this schema/migration (migrations/0001_relationship.sql,
|
|
||||||
* applied under its own `migrations_table` so it doesn't clash with the other
|
|
||||||
* workers' migrations that share the database).
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Relationship state from the perspective of the player asking (mirrors the reference). */
|
|
||||||
export enum RelationshipType {
|
|
||||||
None = 0,
|
|
||||||
FriendRequestSent = 1,
|
|
||||||
FriendRequestReceived = 2,
|
|
||||||
Friend = 3,
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Schema DDL (mirror of migrations/0001_relationship.sql, sans seed rows). */
|
|
||||||
export const SCHEMA_DDL: string[] = [
|
|
||||||
`CREATE TABLE IF NOT EXISTS relationship (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
requester_id INTEGER NOT NULL,
|
|
||||||
target_id INTEGER NOT NULL,
|
|
||||||
relationship_type INTEGER NOT NULL DEFAULT 0,
|
|
||||||
requester_favorited INTEGER NOT NULL DEFAULT 0,
|
|
||||||
requester_ignored INTEGER NOT NULL DEFAULT 0,
|
|
||||||
requester_muted INTEGER NOT NULL DEFAULT 0,
|
|
||||||
target_favorited INTEGER NOT NULL DEFAULT 0,
|
|
||||||
target_ignored INTEGER NOT NULL DEFAULT 0,
|
|
||||||
target_muted INTEGER NOT NULL DEFAULT 0
|
|
||||||
)`,
|
|
||||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_relationship ON relationship (requester_id, target_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_relationship_target ON relationship (target_id)`,
|
|
||||||
]
|
|
||||||
|
|
||||||
/** A stored relationship row (snake_case columns, one row per player pair). */
|
|
||||||
interface RelationshipRow {
|
|
||||||
requester_id: number
|
|
||||||
target_id: number
|
|
||||||
relationship_type: number
|
|
||||||
requester_favorited: number
|
|
||||||
requester_ignored: number
|
|
||||||
requester_muted: number
|
|
||||||
target_favorited: number
|
|
||||||
target_ignored: number
|
|
||||||
target_muted: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The per-player relationship projection returned to the client (RelationshipResponse). */
|
|
||||||
export interface RelationshipResponse {
|
|
||||||
Favorited: number
|
|
||||||
Ignored: number
|
|
||||||
Muted: number
|
|
||||||
PlayerID: number
|
|
||||||
RelationshipType: RelationshipType
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The result of a friend-graph mutation. These changes are visible to BOTH players, and
|
|
||||||
* each sees a different projection of the same row (the target of a request sees
|
|
||||||
* `FriendRequestReceived` where the sender sees `Sent`), so callers get both — `self` for
|
|
||||||
* the HTTP response and the acting player's notification, `other` for the target's.
|
|
||||||
*
|
|
||||||
* `changed` is false when the mutation was a no-op: re-sending a request that's already
|
|
||||||
* outstanding, befriending someone you're already friends with, accepting something that
|
|
||||||
* isn't pending. Nothing was written, so no RelationshipChanged notification should go out
|
|
||||||
* (the reference server is likewise silent on its no-change branch).
|
|
||||||
*/
|
|
||||||
export interface RelationshipChange {
|
|
||||||
self: RelationshipResponse
|
|
||||||
other: RelationshipResponse
|
|
||||||
changed: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The projection reported for a pair with no stored relationship. */
|
|
||||||
function noneResponse(otherId: number): RelationshipResponse {
|
|
||||||
return {
|
|
||||||
PlayerID: otherId,
|
|
||||||
RelationshipType: RelationshipType.None,
|
|
||||||
Favorited: 0,
|
|
||||||
Ignored: 0,
|
|
||||||
Muted: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Flip a pending request to the other side's point of view; Friend/None are symmetric. */
|
|
||||||
function flipType(type: number): RelationshipType {
|
|
||||||
if (type === RelationshipType.FriendRequestSent) return RelationshipType.FriendRequestReceived
|
|
||||||
if (type === RelationshipType.FriendRequestReceived) return RelationshipType.FriendRequestSent
|
|
||||||
return type as RelationshipType
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Project a stored row into the RelationshipResponse for `playerId` (who must be
|
|
||||||
* one of the pair). `PlayerID` is the *other* player; the type and the
|
|
||||||
* favorited/ignored/muted flags are taken from `playerId`'s side of the row.
|
|
||||||
*/
|
|
||||||
function toResponse(row: RelationshipRow, playerId: number): RelationshipResponse {
|
|
||||||
const isRequester = row.requester_id === playerId
|
|
||||||
return {
|
|
||||||
PlayerID: isRequester ? row.target_id : row.requester_id,
|
|
||||||
RelationshipType: isRequester ? (row.relationship_type as RelationshipType) : flipType(row.relationship_type),
|
|
||||||
Favorited: isRequester ? row.requester_favorited : row.target_favorited,
|
|
||||||
Ignored: isRequester ? row.requester_ignored : row.target_ignored,
|
|
||||||
Muted: isRequester ? row.requester_muted : row.target_muted,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Project a written row for both players in the pair. */
|
|
||||||
function toChange(
|
|
||||||
row: RelationshipRow,
|
|
||||||
playerId: number,
|
|
||||||
otherId: number,
|
|
||||||
changed: boolean
|
|
||||||
): RelationshipChange {
|
|
||||||
return { self: toResponse(row, playerId), other: toResponse(row, otherId), changed }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Find the single row for an unordered pair (either direction), or null. */
|
|
||||||
async function findPair(db: D1Database, a: number, b: number): Promise<RelationshipRow | null> {
|
|
||||||
return db
|
|
||||||
.prepare(
|
|
||||||
`SELECT * FROM relationship
|
|
||||||
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
|
|
||||||
)
|
|
||||||
.bind(a, b)
|
|
||||||
.first<RelationshipRow>()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All of a player's relationships, projected from that player's point of view.
|
|
||||||
*
|
|
||||||
* `None` rows are included: they are how an unfriending, or an ignore/mute of someone you
|
|
||||||
* were never friends with, is recorded, and they still carry that player's
|
|
||||||
* favorited/ignored/muted flags. Dropping them would lose the flags on the client.
|
|
||||||
*/
|
|
||||||
export async function getRelationshipsForPlayer(
|
|
||||||
db: D1Database,
|
|
||||||
playerId: number
|
|
||||||
): Promise<RelationshipResponse[]> {
|
|
||||||
const { results } = await db
|
|
||||||
.prepare(
|
|
||||||
`SELECT * FROM relationship
|
|
||||||
WHERE requester_id = ?1 OR target_id = ?1`
|
|
||||||
)
|
|
||||||
.bind(playerId)
|
|
||||||
.all<RelationshipRow>()
|
|
||||||
return results.map((row) => toResponse(row, playerId))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The ids of everyone a player is actually friends with — `Friend` rows only, from
|
|
||||||
* either side of the pair (the row records one direction, the friendship is mutual).
|
|
||||||
* Pending requests and `None` rows are excluded, unlike
|
|
||||||
* {@link getRelationshipsForPlayer}, which reports the whole graph.
|
|
||||||
*/
|
|
||||||
export async function getFriendIds(db: D1Database, playerId: number): Promise<number[]> {
|
|
||||||
const { results } = await db
|
|
||||||
.prepare(
|
|
||||||
`SELECT CASE WHEN requester_id = ?1 THEN target_id ELSE requester_id END AS id
|
|
||||||
FROM relationship
|
|
||||||
WHERE relationship_type = ?2 AND (requester_id = ?1 OR target_id = ?1)`
|
|
||||||
)
|
|
||||||
.bind(playerId, RelationshipType.Friend)
|
|
||||||
.all<{ id: number }>()
|
|
||||||
return results.map((r) => r.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How many mutual friends the mutual-friends lookup will return at most. */
|
|
||||||
export const MUTUAL_FRIENDS_LIMIT = 100
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The ids two players are both friends with — the intersection of their friend lists,
|
|
||||||
* ascending and capped at {@link MUTUAL_FRIENDS_LIMIT}. Each player's friends are a
|
|
||||||
* small set, so the intersection is done in memory rather than as a SQL INTERSECT.
|
|
||||||
*/
|
|
||||||
export async function getMutualFriendIds(
|
|
||||||
db: D1Database,
|
|
||||||
playerId: number,
|
|
||||||
otherId: number
|
|
||||||
): Promise<number[]> {
|
|
||||||
const [mine, theirs] = await Promise.all([
|
|
||||||
getFriendIds(db, playerId),
|
|
||||||
getFriendIds(db, otherId),
|
|
||||||
])
|
|
||||||
const ours = new Set(theirs)
|
|
||||||
return mine
|
|
||||||
.filter((id) => ours.has(id))
|
|
||||||
.sort((a, b) => a - b)
|
|
||||||
.slice(0, MUTUAL_FRIENDS_LIMIT)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persist `type` for the pair, with `requesterId` recorded as the row's
|
|
||||||
* requester. Inserts a new row or, if one already exists for the pair (either
|
|
||||||
* direction), rewrites it so the requester is normalized to `requesterId` and
|
|
||||||
* the flags are preserved for whichever side each player is on. Returns the
|
|
||||||
* row as written, for the caller to project onto whichever side it needs.
|
|
||||||
*/
|
|
||||||
async function upsertPair(
|
|
||||||
db: D1Database,
|
|
||||||
requesterId: number,
|
|
||||||
targetId: number,
|
|
||||||
type: RelationshipType
|
|
||||||
): Promise<RelationshipRow> {
|
|
||||||
const existing = await findPair(db, requesterId, targetId)
|
|
||||||
if (!existing) {
|
|
||||||
await db
|
|
||||||
.prepare(
|
|
||||||
`INSERT INTO relationship (requester_id, target_id, relationship_type)
|
|
||||||
VALUES (?1, ?2, ?3)`
|
|
||||||
)
|
|
||||||
.bind(requesterId, targetId, type)
|
|
||||||
.run()
|
|
||||||
return {
|
|
||||||
requester_id: requesterId,
|
|
||||||
target_id: targetId,
|
|
||||||
relationship_type: type,
|
|
||||||
requester_favorited: 0,
|
|
||||||
requester_ignored: 0,
|
|
||||||
requester_muted: 0,
|
|
||||||
target_favorited: 0,
|
|
||||||
target_ignored: 0,
|
|
||||||
target_muted: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep each player's flags with that player as the row is normalized to
|
|
||||||
// requester = requesterId.
|
|
||||||
const reqIsRequester = existing.requester_id === requesterId
|
|
||||||
const reqFlags = {
|
|
||||||
favorited: reqIsRequester ? existing.requester_favorited : existing.target_favorited,
|
|
||||||
ignored: reqIsRequester ? existing.requester_ignored : existing.target_ignored,
|
|
||||||
muted: reqIsRequester ? existing.requester_muted : existing.target_muted,
|
|
||||||
}
|
|
||||||
const tgtFlags = {
|
|
||||||
favorited: reqIsRequester ? existing.target_favorited : existing.requester_favorited,
|
|
||||||
ignored: reqIsRequester ? existing.target_ignored : existing.requester_ignored,
|
|
||||||
muted: reqIsRequester ? existing.target_muted : existing.requester_muted,
|
|
||||||
}
|
|
||||||
await db
|
|
||||||
.prepare(
|
|
||||||
`UPDATE relationship
|
|
||||||
SET requester_id = ?1, target_id = ?2, relationship_type = ?3,
|
|
||||||
requester_favorited = ?4, requester_ignored = ?5, requester_muted = ?6,
|
|
||||||
target_favorited = ?7, target_ignored = ?8, target_muted = ?9
|
|
||||||
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
|
|
||||||
)
|
|
||||||
.bind(
|
|
||||||
requesterId,
|
|
||||||
targetId,
|
|
||||||
type,
|
|
||||||
reqFlags.favorited,
|
|
||||||
reqFlags.ignored,
|
|
||||||
reqFlags.muted,
|
|
||||||
tgtFlags.favorited,
|
|
||||||
tgtFlags.ignored,
|
|
||||||
tgtFlags.muted
|
|
||||||
)
|
|
||||||
.run()
|
|
||||||
return {
|
|
||||||
requester_id: requesterId,
|
|
||||||
target_id: targetId,
|
|
||||||
relationship_type: type,
|
|
||||||
requester_favorited: reqFlags.favorited,
|
|
||||||
requester_ignored: reqFlags.ignored,
|
|
||||||
requester_muted: reqFlags.muted,
|
|
||||||
target_favorited: tgtFlags.favorited,
|
|
||||||
target_ignored: tgtFlags.ignored,
|
|
||||||
target_muted: tgtFlags.muted,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a friend request from `requesterId` to `targetId`. If the target already
|
|
||||||
* has a pending request out to the requester, the two become friends instead
|
|
||||||
* (the request crosses an existing one). Already-friends, and re-sending a request
|
|
||||||
* that's already outstanding, are no-ops.
|
|
||||||
*/
|
|
||||||
export async function sendFriendRequest(
|
|
||||||
db: D1Database,
|
|
||||||
requesterId: number,
|
|
||||||
targetId: number
|
|
||||||
): Promise<RelationshipChange> {
|
|
||||||
const existing = await findPair(db, requesterId, targetId)
|
|
||||||
if (existing) {
|
|
||||||
// Already friends, or we already have a request out to them — nothing to write.
|
|
||||||
if (
|
|
||||||
existing.relationship_type === RelationshipType.Friend ||
|
|
||||||
(existing.requester_id === requesterId &&
|
|
||||||
existing.relationship_type === RelationshipType.FriendRequestSent)
|
|
||||||
) {
|
|
||||||
return toChange(existing, requesterId, targetId, false)
|
|
||||||
}
|
|
||||||
// The target already requested us → crossing requests become a friendship.
|
|
||||||
if (
|
|
||||||
existing.requester_id === targetId &&
|
|
||||||
existing.relationship_type === RelationshipType.FriendRequestSent
|
|
||||||
) {
|
|
||||||
const row = await upsertPair(db, requesterId, targetId, RelationshipType.Friend)
|
|
||||||
return toChange(row, requesterId, targetId, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const row = await upsertPair(db, requesterId, targetId, RelationshipType.FriendRequestSent)
|
|
||||||
return toChange(row, requesterId, targetId, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `accepterId` accepts a pending friend request from `otherId`. Only upgrades to
|
|
||||||
* Friend when a request from `otherId` is actually pending; otherwise the current
|
|
||||||
* state is returned as a no-op. (The reference server answers 403 there instead;
|
|
||||||
* we stay lenient, but either way nothing changed.)
|
|
||||||
*/
|
|
||||||
export async function acceptFriendRequest(
|
|
||||||
db: D1Database,
|
|
||||||
accepterId: number,
|
|
||||||
otherId: number
|
|
||||||
): Promise<RelationshipChange> {
|
|
||||||
const existing = await findPair(db, accepterId, otherId)
|
|
||||||
if (
|
|
||||||
existing &&
|
|
||||||
existing.requester_id === otherId &&
|
|
||||||
existing.relationship_type === RelationshipType.FriendRequestSent
|
|
||||||
) {
|
|
||||||
const row = await upsertPair(db, otherId, accepterId, RelationshipType.Friend)
|
|
||||||
return toChange(row, accepterId, otherId, true)
|
|
||||||
}
|
|
||||||
return existing
|
|
||||||
? toChange(existing, accepterId, otherId, false)
|
|
||||||
: { self: noneResponse(otherId), other: noneResponse(accepterId), changed: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Directly make `requesterId` and `targetId` friends (no pending request step).
|
|
||||||
*/
|
|
||||||
export async function addFriend(
|
|
||||||
db: D1Database,
|
|
||||||
requesterId: number,
|
|
||||||
targetId: number
|
|
||||||
): Promise<RelationshipChange> {
|
|
||||||
const existing = await findPair(db, requesterId, targetId)
|
|
||||||
if (existing && existing.relationship_type === RelationshipType.Friend) {
|
|
||||||
return toChange(existing, requesterId, targetId, false)
|
|
||||||
}
|
|
||||||
const row = await upsertPair(db, requesterId, targetId, RelationshipType.Friend)
|
|
||||||
return toChange(row, requesterId, targetId, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove any relationship between the two players (unfriend / cancel request /
|
|
||||||
* decline).
|
|
||||||
*
|
|
||||||
* The row is set to `None` rather than deleted, matching the reference server: the
|
|
||||||
* per-player favorited/ignored/muted flags live on that row and must survive an
|
|
||||||
* unfriending (someone you ignored stays ignored after you drop them as a friend).
|
|
||||||
*/
|
|
||||||
export async function removeFriend(
|
|
||||||
db: D1Database,
|
|
||||||
playerId: number,
|
|
||||||
otherId: number
|
|
||||||
): Promise<RelationshipChange> {
|
|
||||||
await db
|
|
||||||
.prepare(
|
|
||||||
`UPDATE relationship SET relationship_type = ?3
|
|
||||||
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
|
|
||||||
)
|
|
||||||
.bind(playerId, otherId, RelationshipType.None)
|
|
||||||
.run()
|
|
||||||
const updated = await findPair(db, playerId, otherId)
|
|
||||||
return updated
|
|
||||||
? toChange(updated, playerId, otherId, true)
|
|
||||||
: { self: noneResponse(otherId), other: noneResponse(playerId), changed: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A per-player relationship flag — each is stored on the player's own side of the row. */
|
|
||||||
export type RelationshipFlag = 'favorited' | 'ignored' | 'muted'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set one of `playerId`'s per-side flags (favorited/ignored/muted) on their
|
|
||||||
* relationship with `otherId`. These flags are stored per player, so the write
|
|
||||||
* targets the caller's OWN side of the row — `requester_*` when the caller
|
|
||||||
* initiated the pair, `target_*` otherwise. When the pair has no relationship yet
|
|
||||||
* (you can ignore/mute someone you aren't friends with) a fresh `None` row is
|
|
||||||
* created with the caller as requester. Returns the relationship from `playerId`'s
|
|
||||||
* point of view. The `flag`/side names are a fixed union, so interpolating them
|
|
||||||
* into the SQL is safe (same pattern as the room interaction toggles).
|
|
||||||
*/
|
|
||||||
export async function setRelationshipFlag(
|
|
||||||
db: D1Database,
|
|
||||||
playerId: number,
|
|
||||||
otherId: number,
|
|
||||||
flag: RelationshipFlag,
|
|
||||||
value: boolean
|
|
||||||
): Promise<RelationshipResponse> {
|
|
||||||
const existing = await findPair(db, playerId, otherId)
|
|
||||||
const v = value ? 1 : 0
|
|
||||||
if (!existing) {
|
|
||||||
// New row: the caller is the requester, so the flag lives on the requester side.
|
|
||||||
await db
|
|
||||||
.prepare(
|
|
||||||
`INSERT INTO relationship (requester_id, target_id, relationship_type, requester_${flag})
|
|
||||||
VALUES (?1, ?2, ?3, ?4)`
|
|
||||||
)
|
|
||||||
.bind(playerId, otherId, RelationshipType.None, v)
|
|
||||||
.run()
|
|
||||||
} else {
|
|
||||||
// Update whichever side the caller is on, leaving the other player's flag alone.
|
|
||||||
const side = existing.requester_id === playerId ? 'requester' : 'target'
|
|
||||||
await db
|
|
||||||
.prepare(
|
|
||||||
`UPDATE relationship SET ${side}_${flag} = ?3
|
|
||||||
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
|
|
||||||
)
|
|
||||||
.bind(playerId, otherId, v)
|
|
||||||
.run()
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await findPair(db, playerId, otherId)
|
|
||||||
return updated ? toResponse(updated, playerId) : noneResponse(otherId)
|
|
||||||
}
|
|
||||||
@@ -14,13 +14,12 @@ import {
|
|||||||
KeepsakeConfig,
|
KeepsakeConfig,
|
||||||
SanitizeRequest,
|
SanitizeRequest,
|
||||||
stringParam,
|
stringParam,
|
||||||
SubscriptionResponse,
|
|
||||||
} from '../openapi'
|
} from '../openapi'
|
||||||
|
|
||||||
import type { App } from '../context'
|
import type { App } from '../context'
|
||||||
|
|
||||||
// Text sanitization, keepsakes, objectives/events/rewards, and the misc
|
// Text sanitization, keepsakes, objectives/events/rewards, and the misc analytics
|
||||||
// analytics/subscription sinks the client hits during load.
|
// sinks the client hits during load.
|
||||||
export const gameplayRoutes = new Hono<App>({ strict: false })
|
export const gameplayRoutes = new Hono<App>({ strict: false })
|
||||||
// Text sanitization (display names, room names, chat). `v1` echoes the input
|
// Text sanitization (display names, room names, chat). `v1` echoes the input
|
||||||
// value back; `isPure` reports the text is clean.
|
// value back; `isPure` reports the text is clean.
|
||||||
@@ -150,17 +149,3 @@ export const gameplayRoutes = new Hono<App>({ strict: false })
|
|||||||
}),
|
}),
|
||||||
(c) => c.body(null, 200)
|
(c) => c.body(null, 200)
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---- Subscription ---------------------------------------------------------
|
|
||||||
.post(
|
|
||||||
'/api/CampusCard/v1/UpdateAndGetSubscription',
|
|
||||||
describeRoute({
|
|
||||||
tags: ['Gameplay'],
|
|
||||||
summary: 'The caller’s subscription',
|
|
||||||
description:
|
|
||||||
'Rec Room Plus subscription state. There are no subscriptions on this server, so ' +
|
|
||||||
'both fields are null. Also served by the `econ` worker on its own host.',
|
|
||||||
responses: { 200: json(SubscriptionResponse, 'No subscription') },
|
|
||||||
}),
|
|
||||||
(c) => c.json({ subscription: null, platformAccountSubscribedPlayerId: null })
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { describeRoute } from 'hono-openapi'
|
import { describeRoute } from 'hono-openapi'
|
||||||
|
|
||||||
import { authedId, unauthorized } from '../http'
|
|
||||||
import {
|
import {
|
||||||
createImage,
|
createImage,
|
||||||
deleteImage,
|
deleteImage,
|
||||||
@@ -16,7 +15,9 @@ import {
|
|||||||
SLIDESHOW_LIMIT,
|
SLIDESHOW_LIMIT,
|
||||||
SLIDESHOW_MAX_LIMIT,
|
SLIDESHOW_MAX_LIMIT,
|
||||||
toImagesPlayer,
|
toImagesPlayer,
|
||||||
} from '../images-db'
|
} from '@repo/domain'
|
||||||
|
|
||||||
|
import { authedId, unauthorized } from '../http'
|
||||||
import {
|
import {
|
||||||
AUTHED,
|
AUTHED,
|
||||||
CheeredEntry,
|
CheeredEntry,
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { describeRoute } from 'hono-openapi'
|
import { describeRoute } from 'hono-openapi'
|
||||||
|
|
||||||
import { getAccountsByIds } from '@repo/domain'
|
import {
|
||||||
|
acceptFriendRequest,
|
||||||
|
addFriend,
|
||||||
|
getAccountsByIds,
|
||||||
|
getMutualFriendIds,
|
||||||
|
getRelationshipsForPlayer,
|
||||||
|
MUTUAL_FRIENDS_LIMIT,
|
||||||
|
removeFriend,
|
||||||
|
sendFriendRequest,
|
||||||
|
setRelationshipFlag,
|
||||||
|
} from '@repo/domain'
|
||||||
import { logger } from '@repo/hono-helpers'
|
import { logger } from '@repo/hono-helpers'
|
||||||
|
|
||||||
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
|
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
|
||||||
@@ -24,24 +34,14 @@ import {
|
|||||||
SuccessErrorEnvelope,
|
SuccessErrorEnvelope,
|
||||||
UNAUTHORIZED_RESPONSE,
|
UNAUTHORIZED_RESPONSE,
|
||||||
} from '../openapi'
|
} from '../openapi'
|
||||||
import {
|
|
||||||
acceptFriendRequest,
|
|
||||||
addFriend,
|
|
||||||
getMutualFriendIds,
|
|
||||||
getRelationshipsForPlayer,
|
|
||||||
MUTUAL_FRIENDS_LIMIT,
|
|
||||||
removeFriend,
|
|
||||||
sendFriendRequest,
|
|
||||||
setRelationshipFlag,
|
|
||||||
} from '../relationships-db'
|
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { App } from '../context'
|
|
||||||
import type {
|
import type {
|
||||||
RelationshipChange,
|
RelationshipChange,
|
||||||
RelationshipFlag,
|
RelationshipFlag,
|
||||||
RelationshipResponse,
|
RelationshipResponse,
|
||||||
} from '../relationships-db'
|
} from '@repo/domain'
|
||||||
|
import type { App } from '../context'
|
||||||
|
|
||||||
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||||||
const HUB_INSTANCE = 'global'
|
const HUB_INSTANCE = 'global'
|
||||||
|
|||||||
@@ -5,13 +5,17 @@ import { beforeAll, describe, expect, test } from 'vitest'
|
|||||||
import {
|
import {
|
||||||
addXp,
|
addXp,
|
||||||
applyLevelUps,
|
applyLevelUps,
|
||||||
|
createImage,
|
||||||
GAME_VERSION,
|
GAME_VERSION,
|
||||||
|
getImageByName,
|
||||||
grantInvention,
|
grantInvention,
|
||||||
|
IMAGE_SCHEMA_DDL,
|
||||||
INVENTORY_INVENTION_SCHEMA_DDL,
|
INVENTORY_INVENTION_SCHEMA_DDL,
|
||||||
LEVEL_REQUIRED_XP,
|
LEVEL_REQUIRED_XP,
|
||||||
LEVEL_REWARDS,
|
LEVEL_REWARDS,
|
||||||
MAX_LEVEL,
|
MAX_LEVEL,
|
||||||
PROGRESSION_SCHEMA_DDL,
|
PROGRESSION_SCHEMA_DDL,
|
||||||
|
RELATIONSHIP_SCHEMA_DDL,
|
||||||
ROOM_SCHEMA_DDL,
|
ROOM_SCHEMA_DDL,
|
||||||
seedRoomWithSubRooms,
|
seedRoomWithSubRooms,
|
||||||
SUBROOM_SCHEMA_DDL,
|
SUBROOM_SCHEMA_DDL,
|
||||||
@@ -27,9 +31,7 @@ import {
|
|||||||
getEventAttendees,
|
getEventAttendees,
|
||||||
getEventResponse,
|
getEventResponse,
|
||||||
} from '../../events-db'
|
} from '../../events-db'
|
||||||
import { createImage, getImageByName, SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db'
|
|
||||||
import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL } from '../../inventions-db'
|
import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL } from '../../inventions-db'
|
||||||
import { SCHEMA_DDL as RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-db'
|
|
||||||
import {
|
import {
|
||||||
banFromReport,
|
banFromReport,
|
||||||
createReport,
|
createReport,
|
||||||
@@ -40,9 +42,9 @@ import {
|
|||||||
} from '../../reports-db'
|
} from '../../reports-db'
|
||||||
import { getWarningsAgainst, SCHEMA_DDL as WARNINGS_SCHEMA_DDL } from '../../warnings-db'
|
import { getWarningsAgainst, SCHEMA_DDL as WARNINGS_SCHEMA_DDL } from '../../warnings-db'
|
||||||
|
|
||||||
|
import type { SavedImage } from '@repo/domain'
|
||||||
import type { Env } from '../../context'
|
import type { Env } from '../../context'
|
||||||
import type { PlayerEvent, PlayerEventResult } from '../../events-db'
|
import type { PlayerEvent, PlayerEventResult } from '../../events-db'
|
||||||
import type { SavedImage } from '../../images-db'
|
|
||||||
import type { InventionSaveResult, SavedInvention } from '../../inventions-db'
|
import type { InventionSaveResult, SavedInvention } from '../../inventions-db'
|
||||||
|
|
||||||
declare module 'cloudflare:test' {
|
declare module 'cloudflare:test' {
|
||||||
@@ -100,10 +102,10 @@ beforeAll(async () => {
|
|||||||
.run()
|
.run()
|
||||||
|
|
||||||
// Images table (owned by the img worker) — uploadsaved records a row here.
|
// Images table (owned by the img worker) — uploadsaved records a row here.
|
||||||
for (const stmt of IMAGES_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of IMAGE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
|
|
||||||
// Relationships table (owned by the api worker) — friendship endpoints use it.
|
// Relationships table (owned by the api worker) — friendship endpoints use it.
|
||||||
for (const stmt of RELATIONSHIPS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of RELATIONSHIP_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
|
|
||||||
// Inventions table (owned by the api worker) — invention save/mine use it.
|
// Inventions table (owned by the api worker) — invention save/mine use it.
|
||||||
for (const stmt of INVENTIONS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of INVENTIONS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
@@ -3246,7 +3248,6 @@ describe('openapi', () => {
|
|||||||
'GET /api/rooms/v1/filters',
|
'GET /api/rooms/v1/filters',
|
||||||
'GET /api/versioncheck/v4',
|
'GET /api/versioncheck/v4',
|
||||||
'GET /voice/config',
|
'GET /voice/config',
|
||||||
'POST /api/CampusCard/v1/UpdateAndGetSubscription',
|
|
||||||
'POST /api/PlayerReporting/v1/deviceId',
|
'POST /api/PlayerReporting/v1/deviceId',
|
||||||
'POST /api/PlayerReporting/v1/hile',
|
'POST /api/PlayerReporting/v1/hile',
|
||||||
'POST /api/PlayerReporting/v3/create',
|
'POST /api/PlayerReporting/v3/create',
|
||||||
|
|||||||
@@ -1,965 +0,0 @@
|
|||||||
/**
|
|
||||||
* Club storage on the shared `recflare` D1 database. A club is a single JSON blob
|
|
||||||
* in the `data` column (the client-facing Club DTO); queryable fields (ClubId,
|
|
||||||
* Name, Category, Visibility, State, CreatorAccountId) are SQLite generated
|
|
||||||
* (virtual) columns extracted from that JSON and indexed — the same JSON-blob
|
|
||||||
* pattern the rooms/accounts tables use. Mirrors the Go/GORM `Club` model.
|
|
||||||
*
|
|
||||||
* Membership lives in a separate `club_member` table (one row per club/account);
|
|
||||||
* the club's `MemberCount` is a denormalized field kept in sync from those rows.
|
|
||||||
*
|
|
||||||
* The `clubs` worker owns this schema/migration (migrations/0001_club.sql, applied
|
|
||||||
* under its own `migrations_table` so it doesn't clash with the other workers'
|
|
||||||
* migrations that share the database). `SCHEMA_DDL` mirrors that migration so tests
|
|
||||||
* can build the tables directly.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getSavedImagesByNames, placeholderSavedImage } from '@repo/domain'
|
|
||||||
|
|
||||||
import type { SavedImage } from '@repo/domain'
|
|
||||||
|
|
||||||
/** Schema DDL (mirror of migrations/0001_club.sql, sans seed rows). */
|
|
||||||
export const SCHEMA_DDL: string[] = [
|
|
||||||
`CREATE TABLE IF NOT EXISTS club (
|
|
||||||
data TEXT NOT NULL,
|
|
||||||
club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ClubId')) VIRTUAL,
|
|
||||||
name_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Name'))) VIRTUAL,
|
|
||||||
category TEXT GENERATED ALWAYS AS (json_extract(data, '$.Category')) VIRTUAL,
|
|
||||||
visibility INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Visibility')) VIRTUAL,
|
|
||||||
state INTEGER GENERATED ALWAYS AS (json_extract(data, '$.State')) VIRTUAL,
|
|
||||||
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL
|
|
||||||
)`,
|
|
||||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_club_club_id ON club (club_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_club_name_lower ON club (name_lower)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_club_category ON club (category)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_club_creator ON club (creator_account_id)`,
|
|
||||||
// Club membership — one row per (club, account); `membership_type` (see
|
|
||||||
// ClubMembershipType) encodes bans, pending requests/invites, and roles in a
|
|
||||||
// single field. Surrogate PK mirrors the Go model; the UNIQUE (club_id,
|
|
||||||
// account_id) index enforces one membership per pair (and backs the upsert). The
|
|
||||||
// club's MemberCount is kept in sync from the rows that count as real members.
|
|
||||||
`CREATE TABLE IF NOT EXISTS club_member (
|
|
||||||
club_member_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
club_id INTEGER NOT NULL,
|
|
||||||
account_id INTEGER NOT NULL,
|
|
||||||
membership_type INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT
|
|
||||||
)`,
|
|
||||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_club_member_pair ON club_member (club_id, account_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_club_member_account ON club_member (account_id)`,
|
|
||||||
// Club announcements — the club's noticeboard, newest first. Columns rather than a
|
|
||||||
// JSON blob (mirroring the Go model), since nothing here is client-shaped beyond
|
|
||||||
// the fields themselves.
|
|
||||||
`CREATE TABLE IF NOT EXISTS club_announcement (
|
|
||||||
announcement_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
club_id INTEGER NOT NULL,
|
|
||||||
account_id INTEGER NOT NULL,
|
|
||||||
title TEXT NOT NULL DEFAULT '',
|
|
||||||
body TEXT NOT NULL DEFAULT '',
|
|
||||||
image_name TEXT NOT NULL DEFAULT '',
|
|
||||||
meta TEXT NOT NULL DEFAULT '',
|
|
||||||
created_at TEXT
|
|
||||||
)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_club_announcement_club ON club_announcement (club_id)`,
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A player's membership state in a club (mirror of the Go `ClubMembershipType`).
|
|
||||||
* The single field spans bans, the pending request/invite states, and the member
|
|
||||||
* role tiers; `Member` (10) is the threshold at/above which someone is an actual
|
|
||||||
* member (below it is pending/none/banned).
|
|
||||||
*/
|
|
||||||
export enum ClubMembershipType {
|
|
||||||
Banned = -1,
|
|
||||||
None = 0,
|
|
||||||
PendingRequested = 1,
|
|
||||||
PendingInvited = 2,
|
|
||||||
PendingDenied = 3,
|
|
||||||
Member = 10,
|
|
||||||
Moderator = 20,
|
|
||||||
Coowner = 30,
|
|
||||||
Creator = 100,
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A club's visibility (mirror of the Go `ClubVisibility`). */
|
|
||||||
export enum ClubVisibility {
|
|
||||||
Private = 0,
|
|
||||||
Public = 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How a player may join a club (mirror of the Go `ClubJoinability`). */
|
|
||||||
export enum ClubJoinability {
|
|
||||||
Open = 0,
|
|
||||||
InviteOnly = 1,
|
|
||||||
AskToJoin = 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Membership types at/above which a row counts as an actual member (not pending/banned). */
|
|
||||||
const MEMBER_THRESHOLD = ClubMembershipType.Member
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Client-facing club shape (PascalCase, mirror of the Go `Club` JSON tags). The
|
|
||||||
* Go model's `CreatedAt` is `json:"-"` — stored but never serialized — so it lives
|
|
||||||
* in the blob (see StoredClub) but is dropped from this DTO.
|
|
||||||
*/
|
|
||||||
export interface Club {
|
|
||||||
ClubId: number
|
|
||||||
Name: string
|
|
||||||
Description: string
|
|
||||||
Category: string
|
|
||||||
Visibility: number
|
|
||||||
Joinability: number
|
|
||||||
AllowJuniors: boolean
|
|
||||||
MainImageName: string
|
|
||||||
ClubType: number
|
|
||||||
ClubhouseRoomId: number | null
|
|
||||||
CreatorAccountId: number
|
|
||||||
IsRRO: boolean
|
|
||||||
MinLevel: number
|
|
||||||
State: number
|
|
||||||
MemberCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The stored club — the DTO plus fields the client never sees on the Club object
|
|
||||||
* itself: `CreatedAt` (`json:"-"` in Go) and the club's custom tags, which the Go
|
|
||||||
* server keeps in a `club_custom_tags` table but which we keep on the blob, since
|
|
||||||
* they're only ever read and written with the club.
|
|
||||||
*/
|
|
||||||
interface StoredClub extends Club {
|
|
||||||
CreatedAt: string
|
|
||||||
CustomTags?: string[]
|
|
||||||
/**
|
|
||||||
* The club's gallery image names, in order (the client PUTs to
|
|
||||||
* `/additionalimage/{index}`). Packed, never sparse: removing one shifts the rest
|
|
||||||
* up, so the list is always the images the club actually has.
|
|
||||||
*/
|
|
||||||
AdditionalImages?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How many gallery images a club has room for (slots 0..2). */
|
|
||||||
export const MAX_ADDITIONAL_IMAGES = 3
|
|
||||||
|
|
||||||
interface ClubRow {
|
|
||||||
data: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Project a stored club to the client DTO (drops the non-serialized CreatedAt). */
|
|
||||||
function toDto(s: StoredClub): Club {
|
|
||||||
return {
|
|
||||||
ClubId: s.ClubId,
|
|
||||||
Name: s.Name,
|
|
||||||
Description: s.Description,
|
|
||||||
Category: s.Category,
|
|
||||||
Visibility: s.Visibility,
|
|
||||||
Joinability: s.Joinability,
|
|
||||||
AllowJuniors: s.AllowJuniors,
|
|
||||||
MainImageName: s.MainImageName,
|
|
||||||
ClubType: s.ClubType,
|
|
||||||
ClubhouseRoomId: s.ClubhouseRoomId,
|
|
||||||
CreatorAccountId: s.CreatorAccountId,
|
|
||||||
IsRRO: s.IsRRO,
|
|
||||||
MinLevel: s.MinLevel,
|
|
||||||
State: s.State,
|
|
||||||
MemberCount: s.MemberCount,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const parseOne = (row: ClubRow | null): Club | null =>
|
|
||||||
row ? toDto(JSON.parse(row.data) as StoredClub) : null
|
|
||||||
const parseAll = (rows: ClubRow[]): Club[] =>
|
|
||||||
rows.map((r) => toDto(JSON.parse(r.data) as StoredClub))
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recompute a club's `MemberCount` from the `club_member` rows and write it back
|
|
||||||
* into the blob (the generated column follows). Returns the fresh count. Keeping
|
|
||||||
* the count derived avoids drift from concurrent joins/leaves.
|
|
||||||
*/
|
|
||||||
async function syncMemberCount(db: D1Database, clubId: number): Promise<number> {
|
|
||||||
const row = await db
|
|
||||||
.prepare('SELECT COUNT(*) AS n FROM club_member WHERE club_id = ?1 AND membership_type >= ?2')
|
|
||||||
.bind(clubId, MEMBER_THRESHOLD)
|
|
||||||
.first<{ n: number }>()
|
|
||||||
const count = row?.n ?? 0
|
|
||||||
await db
|
|
||||||
// CAST to INTEGER: D1 binds a JS number as a SQLite REAL, which json_set would write
|
|
||||||
// into the blob as `"MemberCount":3.0` — and this blob is served to the client.
|
|
||||||
.prepare(
|
|
||||||
"UPDATE club SET data = json_set(data, '$.MemberCount', CAST(?2 AS INTEGER)) WHERE club_id = ?1"
|
|
||||||
)
|
|
||||||
.bind(clubId, count)
|
|
||||||
.run()
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read a player's membership type in a club (None when there's no row). */
|
|
||||||
export async function getMembership(
|
|
||||||
db: D1Database,
|
|
||||||
clubId: number,
|
|
||||||
accountId: number
|
|
||||||
): Promise<ClubMembershipType> {
|
|
||||||
const row = await db
|
|
||||||
.prepare('SELECT membership_type AS t FROM club_member WHERE club_id = ?1 AND account_id = ?2')
|
|
||||||
.bind(clubId, accountId)
|
|
||||||
.first<{ t: number }>()
|
|
||||||
return (row?.t ?? ClubMembershipType.None) as ClubMembershipType
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upsert a player's membership type for a club (one row per pair). `created_at` is
|
|
||||||
* stamped on first insert and preserved on later type changes.
|
|
||||||
*/
|
|
||||||
async function setMembership(
|
|
||||||
db: D1Database,
|
|
||||||
clubId: number,
|
|
||||||
accountId: number,
|
|
||||||
type: ClubMembershipType
|
|
||||||
): Promise<void> {
|
|
||||||
await db
|
|
||||||
.prepare(
|
|
||||||
`INSERT INTO club_member (club_id, account_id, membership_type, created_at)
|
|
||||||
VALUES (?1, ?2, ?3, ?4)
|
|
||||||
ON CONFLICT(club_id, account_id) DO UPDATE SET membership_type = ?3`
|
|
||||||
)
|
|
||||||
.bind(clubId, accountId, type, new Date().toISOString())
|
|
||||||
.run()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fields a caller may supply when creating a club; everything else takes the Go defaults. */
|
|
||||||
export interface NewClub {
|
|
||||||
name: string
|
|
||||||
description?: string
|
|
||||||
category?: string
|
|
||||||
visibility?: number
|
|
||||||
joinability?: number
|
|
||||||
allowJuniors?: boolean
|
|
||||||
mainImageName?: string
|
|
||||||
clubType?: number
|
|
||||||
clubhouseRoomId?: number | null
|
|
||||||
isRRO?: boolean
|
|
||||||
minLevel?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a club owned by `creatorAccountId`. The id is the next free integer (the
|
|
||||||
* Go model uses `autoIncrement:false`, i.e. an app-assigned id). Unset fields fall
|
|
||||||
* back to the Go model's column defaults. The creator is added as the club's first
|
|
||||||
* member (Owner), so the returned club has MemberCount 1.
|
|
||||||
*/
|
|
||||||
export async function createClub(
|
|
||||||
db: D1Database,
|
|
||||||
creatorAccountId: number,
|
|
||||||
input: NewClub
|
|
||||||
): Promise<Club> {
|
|
||||||
const idRow = await db
|
|
||||||
.prepare('SELECT COALESCE(MAX(club_id), 0) + 1 AS next FROM club')
|
|
||||||
.first<{ next: number }>()
|
|
||||||
const clubId = idRow?.next ?? 1
|
|
||||||
const now = new Date().toISOString()
|
|
||||||
|
|
||||||
const stored: StoredClub = {
|
|
||||||
ClubId: clubId,
|
|
||||||
Name: input.name,
|
|
||||||
Description: input.description ?? '',
|
|
||||||
Category: input.category ?? '',
|
|
||||||
Visibility: input.visibility ?? ClubVisibility.Public,
|
|
||||||
Joinability: input.joinability ?? ClubJoinability.Open,
|
|
||||||
AllowJuniors: input.allowJuniors ?? true,
|
|
||||||
MainImageName: input.mainImageName ?? 'DefaultImgPurple',
|
|
||||||
ClubType: input.clubType ?? 0,
|
|
||||||
ClubhouseRoomId: input.clubhouseRoomId ?? null,
|
|
||||||
CreatorAccountId: creatorAccountId,
|
|
||||||
IsRRO: input.isRRO ?? false,
|
|
||||||
MinLevel: input.minLevel ?? 0,
|
|
||||||
State: 0,
|
|
||||||
MemberCount: 0,
|
|
||||||
CreatedAt: now,
|
|
||||||
}
|
|
||||||
await db.prepare('INSERT INTO club (data) VALUES (?1)').bind(JSON.stringify(stored)).run()
|
|
||||||
|
|
||||||
// The creator is the club's first member, joining as its Creator.
|
|
||||||
await setMembership(db, clubId, creatorAccountId, ClubMembershipType.Creator)
|
|
||||||
const count = await syncMemberCount(db, clubId)
|
|
||||||
return { ...toDto(stored), MemberCount: count }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What each membership tier is allowed to do in a club. These are the defaults every
|
|
||||||
* new club gets (co-owners can do everything, moderators can approve/ban, plain
|
|
||||||
* members can do none of it); nothing edits them yet, so they're derived per club
|
|
||||||
* rather than stored.
|
|
||||||
*/
|
|
||||||
export interface ClubPermission {
|
|
||||||
ClubId: number
|
|
||||||
Type: number
|
|
||||||
ApproveMember: boolean
|
|
||||||
BanUnban: boolean
|
|
||||||
CreateEvent: boolean
|
|
||||||
EditDetails: boolean
|
|
||||||
EditPermissionSettings: boolean
|
|
||||||
PostAnnouncement: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function clubPermission(
|
|
||||||
clubId: number,
|
|
||||||
type: ClubMembershipType,
|
|
||||||
granted: Partial<Omit<ClubPermission, 'ClubId' | 'Type'>> = {}
|
|
||||||
): ClubPermission {
|
|
||||||
return {
|
|
||||||
ClubId: clubId,
|
|
||||||
Type: type,
|
|
||||||
ApproveMember: false,
|
|
||||||
BanUnban: false,
|
|
||||||
CreateEvent: false,
|
|
||||||
EditDetails: false,
|
|
||||||
EditPermissionSettings: false,
|
|
||||||
PostAnnouncement: false,
|
|
||||||
...granted,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The club-details payload the client reads from create/details. */
|
|
||||||
export interface ClubDetails {
|
|
||||||
/**
|
|
||||||
* The club's gallery images as whole image records — the same `SavedImage` shape
|
|
||||||
* every other image on the site is served as. The client deserializes these into
|
|
||||||
* objects, so a bare array of names fails its parser ("expected '{'").
|
|
||||||
*/
|
|
||||||
AdditionalImages: SavedImage[]
|
|
||||||
Club: Club
|
|
||||||
ClubId: number
|
|
||||||
CoownerPermissions: ClubPermission
|
|
||||||
CustomTags: string[]
|
|
||||||
MemberPermissions: ClubPermission
|
|
||||||
ModeratorPermissions: ClubPermission
|
|
||||||
MyMembershipType: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the club-details view for a caller. `MyMembershipType` is the caller's own
|
|
||||||
* membership (0 = none, e.g. a signed-out viewer). Additional images (set via
|
|
||||||
* `/additionalimage/{index}`) and custom tags (set via `modifydetails`) both come off
|
|
||||||
* the club's blob.
|
|
||||||
*/
|
|
||||||
export async function getClubDetails(
|
|
||||||
db: D1Database,
|
|
||||||
club: Club,
|
|
||||||
accountId: number | null
|
|
||||||
): Promise<ClubDetails> {
|
|
||||||
return {
|
|
||||||
AdditionalImages: await getClubGallery(db, club.ClubId),
|
|
||||||
Club: club,
|
|
||||||
ClubId: club.ClubId,
|
|
||||||
CoownerPermissions: clubPermission(club.ClubId, ClubMembershipType.Coowner, {
|
|
||||||
ApproveMember: true,
|
|
||||||
BanUnban: true,
|
|
||||||
CreateEvent: true,
|
|
||||||
EditDetails: true,
|
|
||||||
EditPermissionSettings: true,
|
|
||||||
PostAnnouncement: true,
|
|
||||||
}),
|
|
||||||
CustomTags: await getClubCustomTags(db, club.ClubId),
|
|
||||||
MemberPermissions: clubPermission(club.ClubId, ClubMembershipType.Member),
|
|
||||||
ModeratorPermissions: clubPermission(club.ClubId, ClubMembershipType.Moderator, {
|
|
||||||
ApproveMember: true,
|
|
||||||
BanUnban: true,
|
|
||||||
}),
|
|
||||||
MyMembershipType: accountId === null ? 0 : await getMembership(db, club.ClubId, accountId),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A club announcement (mirror of the Go `ClubAnnouncement`). */
|
|
||||||
export interface ClubAnnouncement {
|
|
||||||
AnnouncementId: number
|
|
||||||
ClubId: number
|
|
||||||
AccountId: number
|
|
||||||
Title: string
|
|
||||||
Body: string
|
|
||||||
ImageName: string
|
|
||||||
Meta: string
|
|
||||||
CreatedAt: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A club's announcements, newest first. An unknown club simply has none. */
|
|
||||||
export async function getClubAnnouncements(
|
|
||||||
db: D1Database,
|
|
||||||
clubId: number
|
|
||||||
): Promise<ClubAnnouncement[]> {
|
|
||||||
const { results } = await db
|
|
||||||
.prepare(
|
|
||||||
`SELECT announcement_id, club_id, account_id, title, body, image_name, meta, created_at
|
|
||||||
FROM club_announcement
|
|
||||||
WHERE club_id = ?1
|
|
||||||
ORDER BY created_at DESC, announcement_id DESC`
|
|
||||||
)
|
|
||||||
.bind(clubId)
|
|
||||||
.all<{
|
|
||||||
announcement_id: number
|
|
||||||
club_id: number
|
|
||||||
account_id: number
|
|
||||||
title: string
|
|
||||||
body: string
|
|
||||||
image_name: string
|
|
||||||
meta: string
|
|
||||||
created_at: string | null
|
|
||||||
}>()
|
|
||||||
|
|
||||||
return results.map((r) => ({
|
|
||||||
AnnouncementId: r.announcement_id,
|
|
||||||
ClubId: r.club_id,
|
|
||||||
AccountId: r.account_id,
|
|
||||||
Title: r.title,
|
|
||||||
Body: r.body,
|
|
||||||
ImageName: r.image_name,
|
|
||||||
Meta: r.meta,
|
|
||||||
CreatedAt: r.created_at,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Post an announcement to a club, returning its new id. */
|
|
||||||
export async function createClubAnnouncement(
|
|
||||||
db: D1Database,
|
|
||||||
clubId: number,
|
|
||||||
accountId: number,
|
|
||||||
fields: { title?: string; body?: string; imageName?: string; meta?: string }
|
|
||||||
): Promise<number> {
|
|
||||||
const row = await db
|
|
||||||
.prepare(
|
|
||||||
`INSERT INTO club_announcement (club_id, account_id, title, body, image_name, meta, created_at)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
|
||||||
RETURNING announcement_id`
|
|
||||||
)
|
|
||||||
.bind(
|
|
||||||
clubId,
|
|
||||||
accountId,
|
|
||||||
fields.title ?? '',
|
|
||||||
fields.body ?? '',
|
|
||||||
fields.imageName ?? '',
|
|
||||||
fields.meta ?? '',
|
|
||||||
new Date().toISOString()
|
|
||||||
)
|
|
||||||
.first<{ announcement_id: number }>()
|
|
||||||
return row?.announcement_id ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What club search answers: the page of clubs plus the total that matched. */
|
|
||||||
export interface ClubSearchResult {
|
|
||||||
Clubs: Club[]
|
|
||||||
ContinuationToken: null
|
|
||||||
TotalClubs: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Club search (`/club/search`). Public, non-subscription clubs only. `category` is an
|
|
||||||
* exact (case-insensitive) match, `query` a substring of the name or description.
|
|
||||||
* `sort`: 1 = newest first, 2 = by name, anything else (including the client's 0) =
|
|
||||||
* biggest first, then newest. `count` caps the page — out-of-range values fall back to
|
|
||||||
* 30, as the reference does. `TotalClubs` is the full match count, not the page size.
|
|
||||||
*/
|
|
||||||
export async function searchClubs(
|
|
||||||
db: D1Database,
|
|
||||||
category: string,
|
|
||||||
query: string,
|
|
||||||
sort: string | undefined,
|
|
||||||
count: number
|
|
||||||
): Promise<ClubSearchResult> {
|
|
||||||
const { results } = await db
|
|
||||||
.prepare(
|
|
||||||
`SELECT data FROM club
|
|
||||||
WHERE visibility = ?1
|
|
||||||
AND json_extract(data, '$.ClubType') != ?2`
|
|
||||||
)
|
|
||||||
.bind(ClubVisibility.Public, SUBSCRIPTION_CLUB_TYPE)
|
|
||||||
.all<ClubRow>()
|
|
||||||
|
|
||||||
const stored = results.map((r) => JSON.parse(r.data) as StoredClub)
|
|
||||||
const term = query.trim().toLowerCase()
|
|
||||||
const wanted = category.trim().toLowerCase()
|
|
||||||
|
|
||||||
const matched = stored.filter((club) => {
|
|
||||||
if (wanted !== '' && club.Category.toLowerCase() !== wanted) return false
|
|
||||||
if (term === '') return true
|
|
||||||
return club.Name.toLowerCase().includes(term) || club.Description.toLowerCase().includes(term)
|
|
||||||
})
|
|
||||||
|
|
||||||
const byNewest = (a: StoredClub, b: StoredClub) => b.CreatedAt.localeCompare(a.CreatedAt)
|
|
||||||
matched.sort((a, b) => {
|
|
||||||
if (sort === '1') return byNewest(a, b)
|
|
||||||
if (sort === '2') return a.Name.localeCompare(b.Name)
|
|
||||||
return b.MemberCount - a.MemberCount || byNewest(a, b)
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
Clubs: matched.slice(0, count).map(toDto),
|
|
||||||
ContinuationToken: null,
|
|
||||||
TotalClubs: matched.length,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The player's "home club" — the one whose clubhouse they spawn into. It's a field
|
|
||||||
* on the *account* row (owned by the `auth` worker, on the same shared database, the
|
|
||||||
* way the `api` worker writes the account's profile image), not on the club: one
|
|
||||||
* home club per player.
|
|
||||||
*
|
|
||||||
* Returns null when they haven't set one, when the club is gone, or when it has no
|
|
||||||
* clubhouse room — a home club with nowhere to go isn't usable, and the reference
|
|
||||||
* 404s all three cases identically.
|
|
||||||
*/
|
|
||||||
export async function getHomeClub(db: D1Database, accountId: number): Promise<Club | null> {
|
|
||||||
const row = await db
|
|
||||||
.prepare(
|
|
||||||
"SELECT json_extract(data, '$.homeClubId') AS clubId FROM account WHERE account_id = ?1"
|
|
||||||
)
|
|
||||||
.bind(accountId)
|
|
||||||
.first<{ clubId: number | null }>()
|
|
||||||
if (row?.clubId == null) return null
|
|
||||||
|
|
||||||
const club = await getClub(db, row.clubId)
|
|
||||||
// `== null` catches a club row that predates the field (undefined), not just an
|
|
||||||
// explicit null — either way it has no clubhouse to spawn into.
|
|
||||||
if (club === null || club.ClubhouseRoomId == null) return null
|
|
||||||
return club
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Point the player's home club at `clubId` (stored on their account row). */
|
|
||||||
export async function setHomeClub(
|
|
||||||
db: D1Database,
|
|
||||||
accountId: number,
|
|
||||||
clubId: number
|
|
||||||
): Promise<void> {
|
|
||||||
await db
|
|
||||||
// CAST to INTEGER — see syncMemberCount: a bound JS number lands as a REAL, so this
|
|
||||||
// would otherwise store `"homeClubId":7.0`.
|
|
||||||
.prepare(
|
|
||||||
"UPDATE account SET data = json_set(data, '$.homeClubId', CAST(?2 AS INTEGER)) WHERE account_id = ?1"
|
|
||||||
)
|
|
||||||
.bind(accountId, clubId)
|
|
||||||
.run()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drop the player's home club (the field is removed from their account row, not set
|
|
||||||
* to 0 — `getHomeClub` reads a missing field as "no home club"). Idempotent.
|
|
||||||
*/
|
|
||||||
export async function clearHomeClub(db: D1Database, accountId: number): Promise<void> {
|
|
||||||
await db
|
|
||||||
.prepare("UPDATE account SET data = json_remove(data, '$.homeClubId') WHERE account_id = ?1")
|
|
||||||
.bind(accountId)
|
|
||||||
.run()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A club membership row, as the members list serves it (mirror of the Go `ClubMember`). */
|
|
||||||
export interface ClubMember {
|
|
||||||
ClubMemberId: number
|
|
||||||
ClubId: number
|
|
||||||
AccountId: number
|
|
||||||
MembershipType: number
|
|
||||||
CreatedAt: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A club's members (`/club/:id/members`). `membershipType` filters to exactly that
|
|
||||||
* tier when given — note it's an exact match, not a threshold, so `30` lists only
|
|
||||||
* co-owners (not the creator above them). `sortBy` picks the order: 1 = by account
|
|
||||||
* id, 2 = oldest membership first, anything else = the default, highest tier first
|
|
||||||
* then oldest. An unknown club has no members, so it's an empty list, not a 404.
|
|
||||||
*/
|
|
||||||
export async function getClubMembers(
|
|
||||||
db: D1Database,
|
|
||||||
clubId: number,
|
|
||||||
membershipType: number | undefined,
|
|
||||||
sortBy: string | undefined
|
|
||||||
): Promise<ClubMember[]> {
|
|
||||||
const order =
|
|
||||||
sortBy === '1'
|
|
||||||
? 'account_id ASC'
|
|
||||||
: sortBy === '2'
|
|
||||||
? 'created_at ASC'
|
|
||||||
: 'membership_type DESC, created_at ASC'
|
|
||||||
const filter = membershipType === undefined ? '' : 'AND membership_type = ?2'
|
|
||||||
|
|
||||||
const { results } = await db
|
|
||||||
.prepare(
|
|
||||||
`SELECT club_member_id, club_id, account_id, membership_type, created_at
|
|
||||||
FROM club_member
|
|
||||||
WHERE club_id = ?1 ${filter}
|
|
||||||
ORDER BY ${order}`
|
|
||||||
)
|
|
||||||
.bind(...(membershipType === undefined ? [clubId] : [clubId, membershipType]))
|
|
||||||
.all<{
|
|
||||||
club_member_id: number
|
|
||||||
club_id: number
|
|
||||||
account_id: number
|
|
||||||
membership_type: number
|
|
||||||
created_at: string | null
|
|
||||||
}>()
|
|
||||||
|
|
||||||
return results.map((r) => ({
|
|
||||||
ClubMemberId: r.club_member_id,
|
|
||||||
ClubId: r.club_id,
|
|
||||||
AccountId: r.account_id,
|
|
||||||
MembershipType: r.membership_type,
|
|
||||||
CreatedAt: r.created_at,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fields `modifydetails` can change. Anything left undefined keeps its stored value. */
|
|
||||||
export interface ClubPatch {
|
|
||||||
name?: string
|
|
||||||
description?: string
|
|
||||||
category?: string
|
|
||||||
visibility?: number
|
|
||||||
joinability?: number
|
|
||||||
allowJuniors?: boolean
|
|
||||||
mainImageName?: string
|
|
||||||
minLevel?: number
|
|
||||||
/** Replaces the club's tags wholesale when present; absent leaves them alone. */
|
|
||||||
customTags?: string[]
|
|
||||||
/** The club's clubhouse room; `null` clears it (undefined leaves it alone). */
|
|
||||||
clubhouseRoomId?: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply an edit to a club's details (`modifydetails`). Only the keys present on the
|
|
||||||
* patch change. Custom tags are replaced as a set — trimmed, de-duplicated
|
|
||||||
* case-insensitively, first spelling wins. Returns the updated club, or null when
|
|
||||||
* there's no such club.
|
|
||||||
*/
|
|
||||||
export async function updateClub(
|
|
||||||
db: D1Database,
|
|
||||||
clubId: number,
|
|
||||||
patch: ClubPatch
|
|
||||||
): Promise<Club | null> {
|
|
||||||
const row = await db
|
|
||||||
.prepare('SELECT data FROM club WHERE club_id = ?1')
|
|
||||||
.bind(clubId)
|
|
||||||
.first<ClubRow>()
|
|
||||||
if (row === null) return null
|
|
||||||
const stored = JSON.parse(row.data) as StoredClub
|
|
||||||
|
|
||||||
const updated: StoredClub = {
|
|
||||||
...stored,
|
|
||||||
Name: patch.name ?? stored.Name,
|
|
||||||
Description: patch.description ?? stored.Description,
|
|
||||||
Category: patch.category ?? stored.Category,
|
|
||||||
Visibility: patch.visibility ?? stored.Visibility,
|
|
||||||
Joinability: patch.joinability ?? stored.Joinability,
|
|
||||||
AllowJuniors: patch.allowJuniors ?? stored.AllowJuniors,
|
|
||||||
MainImageName: patch.mainImageName ?? stored.MainImageName,
|
|
||||||
MinLevel: patch.minLevel ?? stored.MinLevel,
|
|
||||||
CustomTags: patch.customTags === undefined ? stored.CustomTags : dedupeTags(patch.customTags),
|
|
||||||
// `null` clears the clubhouse, so this can't collapse to `??`.
|
|
||||||
ClubhouseRoomId:
|
|
||||||
patch.clubhouseRoomId === undefined ? stored.ClubhouseRoomId : patch.clubhouseRoomId,
|
|
||||||
}
|
|
||||||
await db
|
|
||||||
.prepare('UPDATE club SET data = ?1 WHERE club_id = ?2')
|
|
||||||
.bind(JSON.stringify(updated), clubId)
|
|
||||||
.run()
|
|
||||||
return toDto(updated)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Trim, drop blanks, and de-duplicate tags case-insensitively (first spelling wins). */
|
|
||||||
function dedupeTags(tags: string[]): string[] {
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const out: string[] = []
|
|
||||||
for (const raw of tags) {
|
|
||||||
const tag = raw.trim()
|
|
||||||
if (tag === '' || seen.has(tag.toLowerCase())) continue
|
|
||||||
seen.add(tag.toLowerCase())
|
|
||||||
out.push(tag)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A club's gallery image names, in order (stored on the blob; `[]` when it has none). */
|
|
||||||
export async function getClubAdditionalImages(db: D1Database, clubId: number): Promise<string[]> {
|
|
||||||
const row = await db
|
|
||||||
.prepare('SELECT data FROM club WHERE club_id = ?1')
|
|
||||||
.bind(clubId)
|
|
||||||
.first<ClubRow>()
|
|
||||||
return row === null ? [] : ((JSON.parse(row.data) as StoredClub).AdditionalImages ?? [])
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A club's gallery as the client reads it: the image record behind each name, in
|
|
||||||
* order. A name whose metadata row is missing falls back to a placeholder record so
|
|
||||||
* the picture still renders.
|
|
||||||
*/
|
|
||||||
export async function getClubGallery(db: D1Database, clubId: number): Promise<SavedImage[]> {
|
|
||||||
const names = await getClubAdditionalImages(db, clubId)
|
|
||||||
if (names.length === 0) return []
|
|
||||||
const records = await getSavedImagesByNames(db, names)
|
|
||||||
return names.map((name) => records.get(name) ?? placeholderSavedImage(name))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set (or remove, with an empty `imageName`) one of a club's gallery images. The list
|
|
||||||
* stays packed: removing an image shifts the ones after it up, and setting an index
|
|
||||||
* past the end appends rather than leaving a gap. Returns null when the club doesn't
|
|
||||||
* exist; the caller validates the index is in range.
|
|
||||||
*/
|
|
||||||
export async function setClubAdditionalImage(
|
|
||||||
db: D1Database,
|
|
||||||
clubId: number,
|
|
||||||
index: number,
|
|
||||||
imageName: string
|
|
||||||
): Promise<Club | null> {
|
|
||||||
const row = await db
|
|
||||||
.prepare('SELECT data FROM club WHERE club_id = ?1')
|
|
||||||
.bind(clubId)
|
|
||||||
.first<ClubRow>()
|
|
||||||
if (row === null) return null
|
|
||||||
const stored = JSON.parse(row.data) as StoredClub
|
|
||||||
|
|
||||||
const images = [...(stored.AdditionalImages ?? [])]
|
|
||||||
if (imageName === '') {
|
|
||||||
// Removing past the end is a no-op, not an error: the image is already gone.
|
|
||||||
if (index < images.length) images.splice(index, 1)
|
|
||||||
} else if (index < images.length) {
|
|
||||||
images[index] = imageName
|
|
||||||
} else if (images.length < MAX_ADDITIONAL_IMAGES) {
|
|
||||||
images.push(imageName)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated: StoredClub = { ...stored, AdditionalImages: images }
|
|
||||||
await db
|
|
||||||
.prepare('UPDATE club SET data = ?1 WHERE club_id = ?2')
|
|
||||||
.bind(JSON.stringify(updated), clubId)
|
|
||||||
.run()
|
|
||||||
return toDto(updated)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A club's custom tags (stored on the blob; empty when it has none). */
|
|
||||||
export async function getClubCustomTags(db: D1Database, clubId: number): Promise<string[]> {
|
|
||||||
const row = await db
|
|
||||||
.prepare('SELECT data FROM club WHERE club_id = ?1')
|
|
||||||
.bind(clubId)
|
|
||||||
.first<ClubRow>()
|
|
||||||
return row === null ? [] : ((JSON.parse(row.data) as StoredClub).CustomTags ?? [])
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Look up a single club by its ClubId. */
|
|
||||||
export async function getClub(db: D1Database, clubId: number): Promise<Club | null> {
|
|
||||||
return parseOne(
|
|
||||||
await db.prepare('SELECT data FROM club WHERE club_id = ?1').bind(clubId).first<ClubRow>()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a club and everything hanging off it — its memberships and announcements —
|
|
||||||
* and clear it from the home club of anyone who'd set it. Returns false when there
|
|
||||||
* was no such club. Batched so a half-deleted club can't be left behind.
|
|
||||||
*/
|
|
||||||
export async function deleteClub(db: D1Database, clubId: number): Promise<boolean> {
|
|
||||||
if ((await getClub(db, clubId)) === null) return false
|
|
||||||
await db.batch([
|
|
||||||
db.prepare('DELETE FROM club_member WHERE club_id = ?1').bind(clubId),
|
|
||||||
db.prepare('DELETE FROM club_announcement WHERE club_id = ?1').bind(clubId),
|
|
||||||
// The account table belongs to the auth worker; a dangling homeClubId already
|
|
||||||
// reads as "no home club" (getHomeClub), but leaving it would point at whatever
|
|
||||||
// club later reuses the id.
|
|
||||||
db
|
|
||||||
.prepare(
|
|
||||||
`UPDATE account SET data = json_remove(data, '$.homeClubId')
|
|
||||||
WHERE json_extract(data, '$.homeClubId') = ?1`
|
|
||||||
)
|
|
||||||
.bind(clubId),
|
|
||||||
db.prepare('DELETE FROM club WHERE club_id = ?1').bind(clubId),
|
|
||||||
])
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscription clubs (`ClubType` 1) are a creator's paid-subscriber club, not a
|
|
||||||
* club you browse or list among your own — they're excluded from the "my clubs"
|
|
||||||
* lists (the client reaches them through the `/subscription/*` endpoints instead).
|
|
||||||
*/
|
|
||||||
const SUBSCRIPTION_CLUB_TYPE = 1
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How many clubs an account has made, for the per-account club cap. Subscription
|
|
||||||
* clubs don't count — they're provisioned for a creator's subscribers rather than
|
|
||||||
* made by hand, so they shouldn't eat a slot.
|
|
||||||
*/
|
|
||||||
export async function countClubsByCreator(db: D1Database, accountId: number): Promise<number> {
|
|
||||||
const row = await db
|
|
||||||
.prepare(
|
|
||||||
`SELECT COUNT(*) AS n FROM club
|
|
||||||
WHERE creator_account_id = ?1
|
|
||||||
AND json_extract(data, '$.ClubType') != ?2`
|
|
||||||
)
|
|
||||||
.bind(accountId, SUBSCRIPTION_CLUB_TYPE)
|
|
||||||
.first<{ n: number }>()
|
|
||||||
return row?.n ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/** All clubs created by an account (GetMyCreatedClubs), oldest first. */
|
|
||||||
export async function getClubsByCreator(db: D1Database, accountId: number): Promise<Club[]> {
|
|
||||||
const { results } = await db
|
|
||||||
.prepare(
|
|
||||||
`SELECT data FROM club
|
|
||||||
WHERE creator_account_id = ?1
|
|
||||||
AND json_extract(data, '$.ClubType') != ?2
|
|
||||||
ORDER BY json_extract(data, '$.CreatedAt') ASC`
|
|
||||||
)
|
|
||||||
.bind(accountId, SUBSCRIPTION_CLUB_TYPE)
|
|
||||||
.all<ClubRow>()
|
|
||||||
return parseAll(results)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All clubs an account is an actual member of (GetMyMembershipClubs), oldest club
|
|
||||||
* first. Only memberships at/above `Member` count — pending requests, denied
|
|
||||||
* requests, and bans are excluded. Joins `club_member` to `club`, so a membership
|
|
||||||
* whose club is gone is simply absent.
|
|
||||||
*/
|
|
||||||
export async function getClubsByMember(db: D1Database, accountId: number): Promise<Club[]> {
|
|
||||||
const { results } = await db
|
|
||||||
.prepare(
|
|
||||||
`SELECT c.data AS data
|
|
||||||
FROM club_member m
|
|
||||||
JOIN club c ON c.club_id = m.club_id
|
|
||||||
WHERE m.account_id = ?1 AND m.membership_type >= ?2
|
|
||||||
AND json_extract(c.data, '$.ClubType') != ?3
|
|
||||||
ORDER BY json_extract(c.data, '$.CreatedAt') ASC`
|
|
||||||
)
|
|
||||||
.bind(accountId, MEMBER_THRESHOLD, SUBSCRIPTION_CLUB_TYPE)
|
|
||||||
.all<ClubRow>()
|
|
||||||
return parseAll(results)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether an account is an actual member of a club (Member tier or above). */
|
|
||||||
export async function isClubMember(
|
|
||||||
db: D1Database,
|
|
||||||
clubId: number,
|
|
||||||
accountId: number
|
|
||||||
): Promise<boolean> {
|
|
||||||
return (await getMembership(db, clubId, accountId)) >= MEMBER_THRESHOLD
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Have `accountId` join a club. On an Open club they become a `Member` immediately;
|
|
||||||
* on an InviteOnly/AskToJoin club the join is recorded as `PendingRequested` (an
|
|
||||||
* approval flow, not yet a member). Idempotent for anyone already a member, and a
|
|
||||||
* no-op for a banned account. Returns the club with its refreshed MemberCount, or
|
|
||||||
* null when the club doesn't exist.
|
|
||||||
*/
|
|
||||||
export async function joinClub(
|
|
||||||
db: D1Database,
|
|
||||||
clubId: number,
|
|
||||||
accountId: number
|
|
||||||
): Promise<Club | null> {
|
|
||||||
const club = await getClub(db, clubId)
|
|
||||||
if (!club) return null
|
|
||||||
|
|
||||||
const current = await getMembership(db, clubId, accountId)
|
|
||||||
// A ban can't be shed by re-joining, and an existing member/pending stays as-is.
|
|
||||||
if (current === ClubMembershipType.Banned || current >= MEMBER_THRESHOLD) {
|
|
||||||
return club
|
|
||||||
}
|
|
||||||
const next =
|
|
||||||
club.Joinability === ClubJoinability.Open
|
|
||||||
? ClubMembershipType.Member
|
|
||||||
: ClubMembershipType.PendingRequested
|
|
||||||
await setMembership(db, clubId, accountId, next)
|
|
||||||
|
|
||||||
const count = await syncMemberCount(db, clubId)
|
|
||||||
return { ...club, MemberCount: count }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How a request to join resolved. `joined` is an Open club (no approval needed),
|
|
||||||
* `requested` an AskToJoin club (now PendingRequested), `alreadyPending` a repeat
|
|
||||||
* request, `alreadyMember` someone who's already in. `inviteOnly` and `banned` are
|
|
||||||
* refusals — the caller can't get in this way.
|
|
||||||
*/
|
|
||||||
export type JoinRequestResult =
|
|
||||||
'joined' | 'requested' | 'alreadyPending' | 'alreadyMember' | 'inviteOnly' | 'banned'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ask to join a club. Unlike `joinClub` this honours the club's Joinability strictly:
|
|
||||||
* an InviteOnly club can only be entered through an invite, so a request is refused
|
|
||||||
* rather than parked as pending. Returns the outcome plus the club with its refreshed
|
|
||||||
* MemberCount, or null when the club doesn't exist.
|
|
||||||
*/
|
|
||||||
export async function requestToJoinClub(
|
|
||||||
db: D1Database,
|
|
||||||
clubId: number,
|
|
||||||
accountId: number
|
|
||||||
): Promise<{ result: JoinRequestResult; club: Club } | null> {
|
|
||||||
const club = await getClub(db, clubId)
|
|
||||||
if (!club) return null
|
|
||||||
|
|
||||||
const current = await getMembership(db, clubId, accountId)
|
|
||||||
// A ban can't be shed by asking again, and existing members/requests stay as-is.
|
|
||||||
if (current === ClubMembershipType.Banned) return { result: 'banned', club }
|
|
||||||
if (current >= MEMBER_THRESHOLD) return { result: 'alreadyMember', club }
|
|
||||||
if (current === ClubMembershipType.PendingRequested) return { result: 'alreadyPending', club }
|
|
||||||
|
|
||||||
if (club.Joinability === ClubJoinability.InviteOnly) return { result: 'inviteOnly', club }
|
|
||||||
|
|
||||||
const open = club.Joinability === ClubJoinability.Open
|
|
||||||
await setMembership(
|
|
||||||
db,
|
|
||||||
clubId,
|
|
||||||
accountId,
|
|
||||||
open ? ClubMembershipType.Member : ClubMembershipType.PendingRequested
|
|
||||||
)
|
|
||||||
|
|
||||||
const count = await syncMemberCount(db, clubId)
|
|
||||||
return { result: open ? 'joined' : 'requested', club: { ...club, MemberCount: count } }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove `accountId`'s membership of a club (idempotent). A ban is preserved — you
|
|
||||||
* can't clear it by leaving — but any member/pending row is dropped. Returns the
|
|
||||||
* outcome plus the club with its refreshed MemberCount, or null when the club doesn't
|
|
||||||
* exist. The club itself is left in place even when the last member leaves.
|
|
||||||
*
|
|
||||||
* The creator can't leave: a club with no owner has no one who can administer it, and
|
|
||||||
* there's no ownership transfer, so they have to delete the club instead. `creator`
|
|
||||||
* reports that refusal, with the club unchanged.
|
|
||||||
*/
|
|
||||||
export async function leaveClub(
|
|
||||||
db: D1Database,
|
|
||||||
clubId: number,
|
|
||||||
accountId: number
|
|
||||||
): Promise<{ result: 'left' | 'creator'; club: Club } | null> {
|
|
||||||
const club = await getClub(db, clubId)
|
|
||||||
if (!club) return null
|
|
||||||
|
|
||||||
const current = await getMembership(db, clubId, accountId)
|
|
||||||
if (current === ClubMembershipType.Creator) return { result: 'creator', club }
|
|
||||||
|
|
||||||
await db
|
|
||||||
.prepare(
|
|
||||||
'DELETE FROM club_member WHERE club_id = ?1 AND account_id = ?2 AND membership_type <> ?3'
|
|
||||||
)
|
|
||||||
.bind(clubId, accountId, ClubMembershipType.Banned)
|
|
||||||
.run()
|
|
||||||
const count = await syncMemberCount(db, clubId)
|
|
||||||
return { result: 'left', club: { ...club, MemberCount: count } }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set an account's membership tier in a club — the invite / role-assignment write
|
|
||||||
* behind `PUT /club/:id/members/invite`. Upserts the `club_member` row to
|
|
||||||
* `membershipType` (adding the account when it wasn't a member, and overriding a prior
|
|
||||||
* tier or ban), then refreshes the club's MemberCount. Returns the club with its fresh
|
|
||||||
* count, or null when the club is gone. The caller is responsible for checking that the
|
|
||||||
* tier is one it may grant and that the target isn't the club's Creator.
|
|
||||||
*/
|
|
||||||
export async function setMemberType(
|
|
||||||
db: D1Database,
|
|
||||||
clubId: number,
|
|
||||||
accountId: number,
|
|
||||||
membershipType: ClubMembershipType
|
|
||||||
): Promise<Club | null> {
|
|
||||||
const club = await getClub(db, clubId)
|
|
||||||
if (!club) return null
|
|
||||||
await setMembership(db, clubId, accountId, membershipType)
|
|
||||||
const count = await syncMemberCount(db, clubId)
|
|
||||||
return { ...club, MemberCount: count }
|
|
||||||
}
|
|
||||||
@@ -2,14 +2,6 @@ import { Hono } from 'hono'
|
|||||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||||
|
|
||||||
import {
|
|
||||||
glyphLength,
|
|
||||||
MAX_CLUB_DESCRIPTION_LENGTH,
|
|
||||||
MAX_CLUB_NAME_LENGTH,
|
|
||||||
} from '@repo/domain'
|
|
||||||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
|
||||||
import { validateAndGetAccountId } from '@repo/jwt'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
clearHomeClub,
|
clearHomeClub,
|
||||||
ClubJoinability,
|
ClubJoinability,
|
||||||
@@ -27,16 +19,22 @@ import {
|
|||||||
getClubsByMember,
|
getClubsByMember,
|
||||||
getHomeClub,
|
getHomeClub,
|
||||||
getMembership,
|
getMembership,
|
||||||
|
glyphLength,
|
||||||
joinClub,
|
joinClub,
|
||||||
leaveClub,
|
leaveClub,
|
||||||
MAX_ADDITIONAL_IMAGES,
|
MAX_ADDITIONAL_IMAGES,
|
||||||
|
MAX_CLUB_DESCRIPTION_LENGTH,
|
||||||
|
MAX_CLUB_NAME_LENGTH,
|
||||||
requestToJoinClub,
|
requestToJoinClub,
|
||||||
searchClubs,
|
searchClubs,
|
||||||
setClubAdditionalImage,
|
setClubAdditionalImage,
|
||||||
setHomeClub,
|
setHomeClub,
|
||||||
setMemberType,
|
setMemberType,
|
||||||
updateClub,
|
updateClub,
|
||||||
} from './clubs-db'
|
} from '@repo/domain'
|
||||||
|
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
|
import { validateAndGetAccountId } from '@repo/jwt'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AnnouncementIdEnvelope,
|
AnnouncementIdEnvelope,
|
||||||
AnnouncementRequest,
|
AnnouncementRequest,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { beforeAll, describe, expect, test } from 'vitest'
|
|||||||
|
|
||||||
import '../../clubs.app'
|
import '../../clubs.app'
|
||||||
|
|
||||||
import { SCHEMA_DDL } from '../../clubs-db'
|
import { CLUB_SCHEMA_DDL } from '@repo/domain'
|
||||||
|
|
||||||
import type { Env } from '../../context'
|
import type { Env } from '../../context'
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ beforeAll(async () => {
|
|||||||
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
|
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
|
||||||
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
|
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
|
||||||
// Build the club / club_member tables (mirrors the migration).
|
// Build the club / club_member tables (mirrors the migration).
|
||||||
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of CLUB_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
|
|
||||||
// Accounts table (owned by the auth worker) — a player's home club is a field on
|
// Accounts table (owned by the auth worker) — a player's home club is a field on
|
||||||
// their account row, so /club/home/me reads and writes it here.
|
// their account row, so /club/home/me reads and writes it here.
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
/**
|
|
||||||
* Image-metadata storage on the shared `recflare` D1 database. Each image is a
|
|
||||||
* single JSON blob in the `data` column; queryable fields (Id, ImageName,
|
|
||||||
* PlayerId, RoomId) are SQLite generated (virtual) columns extracted from that
|
|
||||||
* JSON — the same JSON-blob pattern the rooms/accounts tables use.
|
|
||||||
*
|
|
||||||
* The `img` worker owns this schema/migration (migrations/0001_images.sql, applied
|
|
||||||
* with its own `migrations_table` so it doesn't clash with the other workers'
|
|
||||||
* migrations on the shared database). The `api` worker writes a row on upload and
|
|
||||||
* reads it back, keeping its own copy of these helpers in sync.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Schema DDL (mirror of migrations/0001_image.sql, sans any seed rows). */
|
|
||||||
export const SCHEMA_DDL: string[] = [
|
|
||||||
`CREATE TABLE IF NOT EXISTS image (
|
|
||||||
data TEXT NOT NULL,
|
|
||||||
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Id')) VIRTUAL,
|
|
||||||
image_name TEXT GENERATED ALWAYS AS (json_extract(data, '$.ImageName')) VIRTUAL,
|
|
||||||
player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.PlayerId')) VIRTUAL,
|
|
||||||
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL
|
|
||||||
)`,
|
|
||||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_image_id ON image (id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_image_image_name ON image (image_name)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_image_player_id ON image (player_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_image_room_id ON image (room_id)`,
|
|
||||||
// A player's interaction with a saved image — one row per (player, image). Only
|
|
||||||
// `cheered` for now; named generically so other per-user interactions (e.g.
|
|
||||||
// favorited) can be added as columns. The `api` worker writes it (cheer endpoints)
|
|
||||||
// and keeps the image's denormalized `CheerCount` in sync from it.
|
|
||||||
`CREATE TABLE IF NOT EXISTS image_interaction (
|
|
||||||
player_id INTEGER NOT NULL,
|
|
||||||
saved_image_id INTEGER NOT NULL,
|
|
||||||
cheered INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT,
|
|
||||||
PRIMARY KEY (player_id, saved_image_id)
|
|
||||||
)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_image_interaction_image ON image_interaction (saved_image_id)`,
|
|
||||||
]
|
|
||||||
|
|
||||||
/** A stored image record (the client-facing SavedImage shape). */
|
|
||||||
export interface SavedImage {
|
|
||||||
Id: number
|
|
||||||
Type: number
|
|
||||||
Accessibility: number
|
|
||||||
AccessibilityLocked: boolean
|
|
||||||
ImageName: string
|
|
||||||
Description: string | null
|
|
||||||
PlayerId: number
|
|
||||||
TaggedPlayerIds: number[]
|
|
||||||
RoomId: number | null
|
|
||||||
PlayerEventId: number | null
|
|
||||||
CreatedAt: string
|
|
||||||
CheerCount: number
|
|
||||||
CommentCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImageRow {
|
|
||||||
data: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fields supplied at upload time (from `imgMeta`); everything else defaults. */
|
|
||||||
export interface NewImage {
|
|
||||||
imageName: string
|
|
||||||
playerId: number
|
|
||||||
type?: number
|
|
||||||
accessibility?: number
|
|
||||||
roomId?: number | null
|
|
||||||
description?: string | null
|
|
||||||
taggedPlayerIds?: number[]
|
|
||||||
playerEventId?: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Insert a new image record for an upload, returning the stored row. */
|
|
||||||
export async function createImage(db: D1Database, input: NewImage): Promise<SavedImage> {
|
|
||||||
// Sequential id: one past the current max (the table starts empty).
|
|
||||||
const row = await db
|
|
||||||
.prepare('SELECT COALESCE(MAX(id), 0) + 1 AS next FROM image')
|
|
||||||
.first<{ next: number }>()
|
|
||||||
const image: SavedImage = {
|
|
||||||
Id: row?.next ?? 1,
|
|
||||||
Type: input.type ?? 1,
|
|
||||||
Accessibility: input.accessibility ?? 1,
|
|
||||||
AccessibilityLocked: false,
|
|
||||||
ImageName: input.imageName,
|
|
||||||
Description: input.description ?? null,
|
|
||||||
PlayerId: input.playerId,
|
|
||||||
TaggedPlayerIds: input.taggedPlayerIds ?? [],
|
|
||||||
RoomId: input.roomId ?? null,
|
|
||||||
PlayerEventId: input.playerEventId ?? null,
|
|
||||||
CreatedAt: new Date().toISOString(),
|
|
||||||
CheerCount: 0,
|
|
||||||
CommentCount: 0,
|
|
||||||
}
|
|
||||||
await db.prepare('INSERT INTO image (data) VALUES (?1)').bind(JSON.stringify(image)).run()
|
|
||||||
return image
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Look up an image record by its ImageName (the R2 key / filename), or null. */
|
|
||||||
export async function getImageByName(db: D1Database, name: string): Promise<SavedImage | null> {
|
|
||||||
const row = await db
|
|
||||||
.prepare('SELECT data FROM image WHERE image_name = ?1')
|
|
||||||
.bind(name)
|
|
||||||
.first<ImageRow>()
|
|
||||||
return row ? (JSON.parse(row.data) as SavedImage) : null
|
|
||||||
}
|
|
||||||
@@ -641,9 +641,11 @@ const app = new Hono<App>()
|
|||||||
|
|
||||||
// "Hot" rooms feed — public, non-dorm rooms ordered by live player count (their
|
// "Hot" rooms feed — public, non-dorm rooms ordered by live player count (their
|
||||||
// instances' presence), then stored engagement, optionally filtered to a single
|
// instances' presence), then stored engagement, optionally filtered to a single
|
||||||
// `tag` (e.g. `rro`). `tag=new` is a pseudo-tag no room carries: it serves the
|
// `tag` (e.g. `rro`). `tag=new` and `tag=community` are pseudo-tags no room
|
||||||
// player-made (non-RRO) rooms newest-first. Paginated via skip/take (take defaults
|
// carries: `new` serves the player-made (non-RRO) rooms newest-first, `community`
|
||||||
// to 100). Returns `{ Results, TotalResults }` like search.
|
// keeps the normal ordering but drops the rooms the Coach account created.
|
||||||
|
// Paginated via skip/take (take defaults to 100). Returns
|
||||||
|
// `{ Results, TotalResults }` like search.
|
||||||
.get(
|
.get(
|
||||||
'/rooms/hot',
|
'/rooms/hot',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
@@ -653,11 +655,16 @@ const app = new Hono<App>()
|
|||||||
'Public, non-dorm rooms ordered by how many players are in them right now — live',
|
'Public, non-dorm rooms ordered by how many players are in them right now — live',
|
||||||
'presence summed across each room’s instances — falling back to stored engagement',
|
'presence summed across each room’s instances — falling back to stored engagement',
|
||||||
'for rooms nobody is in. Optionally narrowed to a single `tag` (the browse screen’s',
|
'for rooms nobody is in. Optionally narrowed to a single `tag` (the browse screen’s',
|
||||||
'filter chips post one, e.g. `rro`). The `new` chip is a pseudo-tag — no room carries',
|
'filter chips post one, e.g. `rro`). The `new` and `community` chips are pseudo-tags —',
|
||||||
'a `new` tag — and instead serves the player-made (non-RRO) rooms, newest first.',
|
'no room carries either. `new` instead serves the player-made (non-RRO) rooms, newest',
|
||||||
|
'first; `community` keeps the ordering above but serves only rooms the Coach account',
|
||||||
|
'(the system account owning the seeded first-party rooms) did not create.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
parameters: [
|
parameters: [
|
||||||
stringQuery('tag', 'Restrict to rooms carrying this tag (or `new`, a pseudo-tag)'),
|
stringQuery(
|
||||||
|
'tag',
|
||||||
|
'Restrict to rooms carrying this tag (or `new`/`community`, pseudo-tags)'
|
||||||
|
),
|
||||||
...pageParams(100),
|
...pageParams(100),
|
||||||
],
|
],
|
||||||
responses: { 200: json(PagedRooms, 'The feed page') },
|
responses: { 200: json(PagedRooms, 'The feed page') },
|
||||||
|
|||||||
@@ -577,6 +577,56 @@ describe('rooms endpoints', () => {
|
|||||||
await env.DB.prepare(`DELETE FROM subroom WHERE room_id IN (${ids})`).run()
|
await env.DB.prepare(`DELETE FROM subroom WHERE room_id IN (${ids})`).run()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('GET /rooms/hot?tag=community serves rooms the Coach account did not create', async () => {
|
||||||
|
type Feed = { Results: Array<{ Name: string }>; TotalResults: number }
|
||||||
|
const feed = async (): Promise<Feed> =>
|
||||||
|
(await (
|
||||||
|
await SELF.fetch(`${ORIGIN}/rooms/hot?tag=community&skip=0&take=100`)
|
||||||
|
).json()) as Feed
|
||||||
|
const names = async (): Promise<string[]> => (await feed()).Results.map((r) => r.Name)
|
||||||
|
|
||||||
|
// No room carries a `community` tag, and every seeded room belongs to Coach
|
||||||
|
// (account 1) — so the feed is empty until another account makes something.
|
||||||
|
expect(await feed()).toEqual({ Results: [], TotalResults: 0 })
|
||||||
|
|
||||||
|
const seeded: number[] = []
|
||||||
|
const seed = async (room: Record<string, unknown>) => {
|
||||||
|
seeded.push(Number(room.RoomId))
|
||||||
|
await seedRoomWithSubRooms(env.DB, {
|
||||||
|
Accessibility: 1,
|
||||||
|
IsDorm: false,
|
||||||
|
CreatorAccountId: 2,
|
||||||
|
...room,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await seed({ RoomId: 9101, Name: 'CommunityOne' })
|
||||||
|
await seed({ RoomId: 9102, Name: 'CommunityTwo' })
|
||||||
|
// Coach's own rooms stay out, and so do non-public rooms as everywhere else.
|
||||||
|
await seed({ RoomId: 9103, Name: 'CoachRoom', CreatorAccountId: 1 })
|
||||||
|
await seed({ RoomId: 9104, Name: 'UnlistedCommunityRoom', Accessibility: 2 })
|
||||||
|
|
||||||
|
// Nobody is in any of them and their stats are all zero, so the feed's normal
|
||||||
|
// ordering falls through to RoomId.
|
||||||
|
expect(await names()).toEqual(['CommunityOne', 'CommunityTwo'])
|
||||||
|
|
||||||
|
// Creator, not RRO-ness, is what `community` filters on — unlike `new`, a
|
||||||
|
// player-made room flagged as an RRO still belongs here.
|
||||||
|
await seed({ RoomId: 9105, Name: 'PlayerMadeRRO', IsRRO: true })
|
||||||
|
expect(await names()).toEqual(['CommunityOne', 'CommunityTwo', 'PlayerMadeRRO'])
|
||||||
|
|
||||||
|
// Paging comes off the same order.
|
||||||
|
const page = (await (
|
||||||
|
await SELF.fetch(`${ORIGIN}/rooms/hot?tag=community&skip=1&take=1`)
|
||||||
|
).json()) as Feed
|
||||||
|
expect(page).toMatchObject({ Results: [{ Name: 'CommunityTwo' }], TotalResults: 3 })
|
||||||
|
|
||||||
|
// Leave the shared feeds as they were for the tests that follow.
|
||||||
|
const ids = seeded.join(',')
|
||||||
|
await env.DB.prepare(`DELETE FROM room WHERE room_id IN (${ids})`).run()
|
||||||
|
await env.DB.prepare(`DELETE FROM subroom WHERE room_id IN (${ids})`).run()
|
||||||
|
})
|
||||||
|
|
||||||
it('GET /rooms/base returns a bare array of base/template rooms (incl. non-public)', async () => {
|
it('GET /rooms/base returns a bare array of base/template rooms (incl. non-public)', async () => {
|
||||||
const res = await SELF.fetch(`${ORIGIN}/rooms/base`)
|
const res = await SELF.fetch(`${ORIGIN}/rooms/base`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
|
|||||||
+956
-27
@@ -1,25 +1,755 @@
|
|||||||
/**
|
/**
|
||||||
* Cross-worker *reads* of the club tables. The `clubs` worker owns the schema and
|
* Club storage on the shared `recflare` D1 database. A club is a single JSON blob
|
||||||
* every write (see apps/clubs/src/clubs-db.ts); this is the narrow view other
|
* in the `data` column (the client-facing Club DTO); queryable fields (ClubId,
|
||||||
* workers need — right now `match`, which has to know a club's clubhouse room and
|
* Name, Category, Visibility, State, CreatorAccountId) are SQLite generated
|
||||||
* whether the player asking for it is actually a member.
|
* (virtual) columns extracted from that JSON and indexed — the same JSON-blob
|
||||||
|
* pattern the rooms/accounts tables use. Mirrors the Go/GORM `Club` model.
|
||||||
*
|
*
|
||||||
* Deliberately read-only, and deliberately small: clubs are a JSON blob in
|
* Membership lives in a separate `club_member` table (one row per club/account);
|
||||||
* `club.data`, so anything that needs the whole DTO should go through the clubs
|
* the club's `MemberCount` is a denormalized field kept in sync from those rows.
|
||||||
* worker's API rather than growing this file into a second copy of its model.
|
*
|
||||||
|
* The `clubs` worker owns this schema/migration (migrations/0001_club.sql, applied
|
||||||
|
* under its own `migrations_table` so it doesn't clash with the other workers'
|
||||||
|
* migrations that share the database) and reaches every write here through its
|
||||||
|
* routes. `CLUB_SCHEMA_DDL` mirrors that migration so tests can build the tables
|
||||||
|
* directly. Other workers read: `match` resolves a club's clubhouse room via
|
||||||
|
* {@link getClubSummary} and gates entry on {@link isClubMember}.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { getSavedImagesByNames, placeholderSavedImage } from './images-db'
|
||||||
|
|
||||||
|
import type { SavedImage } from './images-db'
|
||||||
|
|
||||||
|
/** Schema DDL (mirror of migrations/0001_club.sql, sans seed rows). */
|
||||||
|
export const CLUB_SCHEMA_DDL: string[] = [
|
||||||
|
`CREATE TABLE IF NOT EXISTS club (
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ClubId')) VIRTUAL,
|
||||||
|
name_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Name'))) VIRTUAL,
|
||||||
|
category TEXT GENERATED ALWAYS AS (json_extract(data, '$.Category')) VIRTUAL,
|
||||||
|
visibility INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Visibility')) VIRTUAL,
|
||||||
|
state INTEGER GENERATED ALWAYS AS (json_extract(data, '$.State')) VIRTUAL,
|
||||||
|
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL
|
||||||
|
)`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_club_club_id ON club (club_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_club_name_lower ON club (name_lower)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_club_category ON club (category)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_club_creator ON club (creator_account_id)`,
|
||||||
|
// Club membership — one row per (club, account); `membership_type` (see
|
||||||
|
// ClubMembershipType) encodes bans, pending requests/invites, and roles in a
|
||||||
|
// single field. Surrogate PK mirrors the Go model; the UNIQUE (club_id,
|
||||||
|
// account_id) index enforces one membership per pair (and backs the upsert). The
|
||||||
|
// club's MemberCount is kept in sync from the rows that count as real members.
|
||||||
|
`CREATE TABLE IF NOT EXISTS club_member (
|
||||||
|
club_member_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
club_id INTEGER NOT NULL,
|
||||||
|
account_id INTEGER NOT NULL,
|
||||||
|
membership_type INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT
|
||||||
|
)`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_club_member_pair ON club_member (club_id, account_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_club_member_account ON club_member (account_id)`,
|
||||||
|
// Club announcements — the club's noticeboard, newest first. Columns rather than a
|
||||||
|
// JSON blob (mirroring the Go model), since nothing here is client-shaped beyond
|
||||||
|
// the fields themselves.
|
||||||
|
`CREATE TABLE IF NOT EXISTS club_announcement (
|
||||||
|
announcement_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
club_id INTEGER NOT NULL,
|
||||||
|
account_id INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
body TEXT NOT NULL DEFAULT '',
|
||||||
|
image_name TEXT NOT NULL DEFAULT '',
|
||||||
|
meta TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_club_announcement_club ON club_announcement (club_id)`,
|
||||||
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A player's membership state in a club (mirror of the clubs worker's
|
* A player's membership state in a club (mirror of the Go `ClubMembershipType`).
|
||||||
* `ClubMembershipType`). Only the values other workers reason about are named here;
|
* The single field spans bans, the pending request/invite states, and the member
|
||||||
* the tiers between are just higher numbers.
|
* role tiers; `Member` (10) is the threshold at/above which someone is an actual
|
||||||
|
* member (below it is pending/none/banned).
|
||||||
*/
|
*/
|
||||||
export const CLUB_MEMBERSHIP_BANNED = -1
|
export enum ClubMembershipType {
|
||||||
export const CLUB_MEMBERSHIP_NONE = 0
|
Banned = -1,
|
||||||
/** At/above this, a row is an actual member rather than pending/banned. */
|
None = 0,
|
||||||
export const CLUB_MEMBERSHIP_MEMBER = 10
|
PendingRequested = 1,
|
||||||
|
PendingInvited = 2,
|
||||||
|
PendingDenied = 3,
|
||||||
|
Member = 10,
|
||||||
|
Moderator = 20,
|
||||||
|
Coowner = 30,
|
||||||
|
Creator = 100,
|
||||||
|
}
|
||||||
|
|
||||||
/** The club fields other workers read. */
|
/** A club's visibility (mirror of the Go `ClubVisibility`). */
|
||||||
|
export enum ClubVisibility {
|
||||||
|
Private = 0,
|
||||||
|
Public = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How a player may join a club (mirror of the Go `ClubJoinability`). */
|
||||||
|
export enum ClubJoinability {
|
||||||
|
Open = 0,
|
||||||
|
InviteOnly = 1,
|
||||||
|
AskToJoin = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Membership types at/above which a row counts as an actual member (not pending/banned). */
|
||||||
|
const MEMBER_THRESHOLD = ClubMembershipType.Member
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-facing club shape (PascalCase, mirror of the Go `Club` JSON tags). The
|
||||||
|
* Go model's `CreatedAt` is `json:"-"` — stored but never serialized — so it lives
|
||||||
|
* in the blob (see StoredClub) but is dropped from this DTO.
|
||||||
|
*/
|
||||||
|
export interface Club {
|
||||||
|
ClubId: number
|
||||||
|
Name: string
|
||||||
|
Description: string
|
||||||
|
Category: string
|
||||||
|
Visibility: number
|
||||||
|
Joinability: number
|
||||||
|
AllowJuniors: boolean
|
||||||
|
MainImageName: string
|
||||||
|
ClubType: number
|
||||||
|
ClubhouseRoomId: number | null
|
||||||
|
CreatorAccountId: number
|
||||||
|
IsRRO: boolean
|
||||||
|
MinLevel: number
|
||||||
|
State: number
|
||||||
|
MemberCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stored club — the DTO plus fields the client never sees on the Club object
|
||||||
|
* itself: `CreatedAt` (`json:"-"` in Go) and the club's custom tags, which the Go
|
||||||
|
* server keeps in a `club_custom_tags` table but which we keep on the blob, since
|
||||||
|
* they're only ever read and written with the club.
|
||||||
|
*/
|
||||||
|
interface StoredClub extends Club {
|
||||||
|
CreatedAt: string
|
||||||
|
CustomTags?: string[]
|
||||||
|
/**
|
||||||
|
* The club's gallery image names, in order (the client PUTs to
|
||||||
|
* `/additionalimage/{index}`). Packed, never sparse: removing one shifts the rest
|
||||||
|
* up, so the list is always the images the club actually has.
|
||||||
|
*/
|
||||||
|
AdditionalImages?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many gallery images a club has room for (slots 0..2). */
|
||||||
|
export const MAX_ADDITIONAL_IMAGES = 3
|
||||||
|
|
||||||
|
interface ClubRow {
|
||||||
|
data: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project a stored club to the client DTO (drops the non-serialized CreatedAt). */
|
||||||
|
function toDto(s: StoredClub): Club {
|
||||||
|
return {
|
||||||
|
ClubId: s.ClubId,
|
||||||
|
Name: s.Name,
|
||||||
|
Description: s.Description,
|
||||||
|
Category: s.Category,
|
||||||
|
Visibility: s.Visibility,
|
||||||
|
Joinability: s.Joinability,
|
||||||
|
AllowJuniors: s.AllowJuniors,
|
||||||
|
MainImageName: s.MainImageName,
|
||||||
|
ClubType: s.ClubType,
|
||||||
|
ClubhouseRoomId: s.ClubhouseRoomId,
|
||||||
|
CreatorAccountId: s.CreatorAccountId,
|
||||||
|
IsRRO: s.IsRRO,
|
||||||
|
MinLevel: s.MinLevel,
|
||||||
|
State: s.State,
|
||||||
|
MemberCount: s.MemberCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseOne = (row: ClubRow | null): Club | null =>
|
||||||
|
row ? toDto(JSON.parse(row.data) as StoredClub) : null
|
||||||
|
const parseAll = (rows: ClubRow[]): Club[] =>
|
||||||
|
rows.map((r) => toDto(JSON.parse(r.data) as StoredClub))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recompute a club's `MemberCount` from the `club_member` rows and write it back
|
||||||
|
* into the blob (the generated column follows). Returns the fresh count. Keeping
|
||||||
|
* the count derived avoids drift from concurrent joins/leaves.
|
||||||
|
*/
|
||||||
|
async function syncMemberCount(db: D1Database, clubId: number): Promise<number> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT COUNT(*) AS n FROM club_member WHERE club_id = ?1 AND membership_type >= ?2')
|
||||||
|
.bind(clubId, MEMBER_THRESHOLD)
|
||||||
|
.first<{ n: number }>()
|
||||||
|
const count = row?.n ?? 0
|
||||||
|
await db
|
||||||
|
// CAST to INTEGER: D1 binds a JS number as a SQLite REAL, which json_set would write
|
||||||
|
// into the blob as `"MemberCount":3.0` — and this blob is served to the client.
|
||||||
|
.prepare(
|
||||||
|
"UPDATE club SET data = json_set(data, '$.MemberCount', CAST(?2 AS INTEGER)) WHERE club_id = ?1"
|
||||||
|
)
|
||||||
|
.bind(clubId, count)
|
||||||
|
.run()
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a player's membership type in a club (None when there's no row). */
|
||||||
|
export async function getMembership(
|
||||||
|
db: D1Database,
|
||||||
|
clubId: number,
|
||||||
|
accountId: number
|
||||||
|
): Promise<ClubMembershipType> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT membership_type AS t FROM club_member WHERE club_id = ?1 AND account_id = ?2')
|
||||||
|
.bind(clubId, accountId)
|
||||||
|
.first<{ t: number }>()
|
||||||
|
return (row?.t ?? ClubMembershipType.None) as ClubMembershipType
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert a player's membership type for a club (one row per pair). `created_at` is
|
||||||
|
* stamped on first insert and preserved on later type changes.
|
||||||
|
*/
|
||||||
|
async function setMembership(
|
||||||
|
db: D1Database,
|
||||||
|
clubId: number,
|
||||||
|
accountId: number,
|
||||||
|
type: ClubMembershipType
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO club_member (club_id, account_id, membership_type, created_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)
|
||||||
|
ON CONFLICT(club_id, account_id) DO UPDATE SET membership_type = ?3`
|
||||||
|
)
|
||||||
|
.bind(clubId, accountId, type, new Date().toISOString())
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fields a caller may supply when creating a club; everything else takes the Go defaults. */
|
||||||
|
export interface NewClub {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
category?: string
|
||||||
|
visibility?: number
|
||||||
|
joinability?: number
|
||||||
|
allowJuniors?: boolean
|
||||||
|
mainImageName?: string
|
||||||
|
clubType?: number
|
||||||
|
clubhouseRoomId?: number | null
|
||||||
|
isRRO?: boolean
|
||||||
|
minLevel?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a club owned by `creatorAccountId`. The id is the next free integer (the
|
||||||
|
* Go model uses `autoIncrement:false`, i.e. an app-assigned id). Unset fields fall
|
||||||
|
* back to the Go model's column defaults. The creator is added as the club's first
|
||||||
|
* member (Owner), so the returned club has MemberCount 1.
|
||||||
|
*/
|
||||||
|
export async function createClub(
|
||||||
|
db: D1Database,
|
||||||
|
creatorAccountId: number,
|
||||||
|
input: NewClub
|
||||||
|
): Promise<Club> {
|
||||||
|
const idRow = await db
|
||||||
|
.prepare('SELECT COALESCE(MAX(club_id), 0) + 1 AS next FROM club')
|
||||||
|
.first<{ next: number }>()
|
||||||
|
const clubId = idRow?.next ?? 1
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
const stored: StoredClub = {
|
||||||
|
ClubId: clubId,
|
||||||
|
Name: input.name,
|
||||||
|
Description: input.description ?? '',
|
||||||
|
Category: input.category ?? '',
|
||||||
|
Visibility: input.visibility ?? ClubVisibility.Public,
|
||||||
|
Joinability: input.joinability ?? ClubJoinability.Open,
|
||||||
|
AllowJuniors: input.allowJuniors ?? true,
|
||||||
|
MainImageName: input.mainImageName ?? 'DefaultImgPurple',
|
||||||
|
ClubType: input.clubType ?? 0,
|
||||||
|
ClubhouseRoomId: input.clubhouseRoomId ?? null,
|
||||||
|
CreatorAccountId: creatorAccountId,
|
||||||
|
IsRRO: input.isRRO ?? false,
|
||||||
|
MinLevel: input.minLevel ?? 0,
|
||||||
|
State: 0,
|
||||||
|
MemberCount: 0,
|
||||||
|
CreatedAt: now,
|
||||||
|
}
|
||||||
|
await db.prepare('INSERT INTO club (data) VALUES (?1)').bind(JSON.stringify(stored)).run()
|
||||||
|
|
||||||
|
// The creator is the club's first member, joining as its Creator.
|
||||||
|
await setMembership(db, clubId, creatorAccountId, ClubMembershipType.Creator)
|
||||||
|
const count = await syncMemberCount(db, clubId)
|
||||||
|
return { ...toDto(stored), MemberCount: count }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What each membership tier is allowed to do in a club. These are the defaults every
|
||||||
|
* new club gets (co-owners can do everything, moderators can approve/ban, plain
|
||||||
|
* members can do none of it); nothing edits them yet, so they're derived per club
|
||||||
|
* rather than stored.
|
||||||
|
*/
|
||||||
|
export interface ClubPermission {
|
||||||
|
ClubId: number
|
||||||
|
Type: number
|
||||||
|
ApproveMember: boolean
|
||||||
|
BanUnban: boolean
|
||||||
|
CreateEvent: boolean
|
||||||
|
EditDetails: boolean
|
||||||
|
EditPermissionSettings: boolean
|
||||||
|
PostAnnouncement: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function clubPermission(
|
||||||
|
clubId: number,
|
||||||
|
type: ClubMembershipType,
|
||||||
|
granted: Partial<Omit<ClubPermission, 'ClubId' | 'Type'>> = {}
|
||||||
|
): ClubPermission {
|
||||||
|
return {
|
||||||
|
ClubId: clubId,
|
||||||
|
Type: type,
|
||||||
|
ApproveMember: false,
|
||||||
|
BanUnban: false,
|
||||||
|
CreateEvent: false,
|
||||||
|
EditDetails: false,
|
||||||
|
EditPermissionSettings: false,
|
||||||
|
PostAnnouncement: false,
|
||||||
|
...granted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The club-details payload the client reads from create/details. */
|
||||||
|
export interface ClubDetails {
|
||||||
|
/**
|
||||||
|
* The club's gallery images as whole image records — the same `SavedImage` shape
|
||||||
|
* every other image on the site is served as. The client deserializes these into
|
||||||
|
* objects, so a bare array of names fails its parser ("expected '{'").
|
||||||
|
*/
|
||||||
|
AdditionalImages: SavedImage[]
|
||||||
|
Club: Club
|
||||||
|
ClubId: number
|
||||||
|
CoownerPermissions: ClubPermission
|
||||||
|
CustomTags: string[]
|
||||||
|
MemberPermissions: ClubPermission
|
||||||
|
ModeratorPermissions: ClubPermission
|
||||||
|
MyMembershipType: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the club-details view for a caller. `MyMembershipType` is the caller's own
|
||||||
|
* membership (0 = none, e.g. a signed-out viewer). Additional images (set via
|
||||||
|
* `/additionalimage/{index}`) and custom tags (set via `modifydetails`) both come off
|
||||||
|
* the club's blob.
|
||||||
|
*/
|
||||||
|
export async function getClubDetails(
|
||||||
|
db: D1Database,
|
||||||
|
club: Club,
|
||||||
|
accountId: number | null
|
||||||
|
): Promise<ClubDetails> {
|
||||||
|
return {
|
||||||
|
AdditionalImages: await getClubGallery(db, club.ClubId),
|
||||||
|
Club: club,
|
||||||
|
ClubId: club.ClubId,
|
||||||
|
CoownerPermissions: clubPermission(club.ClubId, ClubMembershipType.Coowner, {
|
||||||
|
ApproveMember: true,
|
||||||
|
BanUnban: true,
|
||||||
|
CreateEvent: true,
|
||||||
|
EditDetails: true,
|
||||||
|
EditPermissionSettings: true,
|
||||||
|
PostAnnouncement: true,
|
||||||
|
}),
|
||||||
|
CustomTags: await getClubCustomTags(db, club.ClubId),
|
||||||
|
MemberPermissions: clubPermission(club.ClubId, ClubMembershipType.Member),
|
||||||
|
ModeratorPermissions: clubPermission(club.ClubId, ClubMembershipType.Moderator, {
|
||||||
|
ApproveMember: true,
|
||||||
|
BanUnban: true,
|
||||||
|
}),
|
||||||
|
MyMembershipType: accountId === null ? 0 : await getMembership(db, club.ClubId, accountId),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A club announcement (mirror of the Go `ClubAnnouncement`). */
|
||||||
|
export interface ClubAnnouncement {
|
||||||
|
AnnouncementId: number
|
||||||
|
ClubId: number
|
||||||
|
AccountId: number
|
||||||
|
Title: string
|
||||||
|
Body: string
|
||||||
|
ImageName: string
|
||||||
|
Meta: string
|
||||||
|
CreatedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A club's announcements, newest first. An unknown club simply has none. */
|
||||||
|
export async function getClubAnnouncements(
|
||||||
|
db: D1Database,
|
||||||
|
clubId: number
|
||||||
|
): Promise<ClubAnnouncement[]> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT announcement_id, club_id, account_id, title, body, image_name, meta, created_at
|
||||||
|
FROM club_announcement
|
||||||
|
WHERE club_id = ?1
|
||||||
|
ORDER BY created_at DESC, announcement_id DESC`
|
||||||
|
)
|
||||||
|
.bind(clubId)
|
||||||
|
.all<{
|
||||||
|
announcement_id: number
|
||||||
|
club_id: number
|
||||||
|
account_id: number
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
image_name: string
|
||||||
|
meta: string
|
||||||
|
created_at: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
return results.map((r) => ({
|
||||||
|
AnnouncementId: r.announcement_id,
|
||||||
|
ClubId: r.club_id,
|
||||||
|
AccountId: r.account_id,
|
||||||
|
Title: r.title,
|
||||||
|
Body: r.body,
|
||||||
|
ImageName: r.image_name,
|
||||||
|
Meta: r.meta,
|
||||||
|
CreatedAt: r.created_at,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Post an announcement to a club, returning its new id. */
|
||||||
|
export async function createClubAnnouncement(
|
||||||
|
db: D1Database,
|
||||||
|
clubId: number,
|
||||||
|
accountId: number,
|
||||||
|
fields: { title?: string; body?: string; imageName?: string; meta?: string }
|
||||||
|
): Promise<number> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO club_announcement (club_id, account_id, title, body, image_name, meta, created_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||||
|
RETURNING announcement_id`
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
clubId,
|
||||||
|
accountId,
|
||||||
|
fields.title ?? '',
|
||||||
|
fields.body ?? '',
|
||||||
|
fields.imageName ?? '',
|
||||||
|
fields.meta ?? '',
|
||||||
|
new Date().toISOString()
|
||||||
|
)
|
||||||
|
.first<{ announcement_id: number }>()
|
||||||
|
return row?.announcement_id ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What club search answers: the page of clubs plus the total that matched. */
|
||||||
|
export interface ClubSearchResult {
|
||||||
|
Clubs: Club[]
|
||||||
|
ContinuationToken: null
|
||||||
|
TotalClubs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Club search (`/club/search`). Public, non-subscription clubs only. `category` is an
|
||||||
|
* exact (case-insensitive) match, `query` a substring of the name or description.
|
||||||
|
* `sort`: 1 = newest first, 2 = by name, anything else (including the client's 0) =
|
||||||
|
* biggest first, then newest. `count` caps the page — out-of-range values fall back to
|
||||||
|
* 30, as the reference does. `TotalClubs` is the full match count, not the page size.
|
||||||
|
*/
|
||||||
|
export async function searchClubs(
|
||||||
|
db: D1Database,
|
||||||
|
category: string,
|
||||||
|
query: string,
|
||||||
|
sort: string | undefined,
|
||||||
|
count: number
|
||||||
|
): Promise<ClubSearchResult> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT data FROM club
|
||||||
|
WHERE visibility = ?1
|
||||||
|
AND json_extract(data, '$.ClubType') != ?2`
|
||||||
|
)
|
||||||
|
.bind(ClubVisibility.Public, SUBSCRIPTION_CLUB_TYPE)
|
||||||
|
.all<ClubRow>()
|
||||||
|
|
||||||
|
const stored = results.map((r) => JSON.parse(r.data) as StoredClub)
|
||||||
|
const term = query.trim().toLowerCase()
|
||||||
|
const wanted = category.trim().toLowerCase()
|
||||||
|
|
||||||
|
const matched = stored.filter((club) => {
|
||||||
|
if (wanted !== '' && club.Category.toLowerCase() !== wanted) return false
|
||||||
|
if (term === '') return true
|
||||||
|
return club.Name.toLowerCase().includes(term) || club.Description.toLowerCase().includes(term)
|
||||||
|
})
|
||||||
|
|
||||||
|
const byNewest = (a: StoredClub, b: StoredClub) => b.CreatedAt.localeCompare(a.CreatedAt)
|
||||||
|
matched.sort((a, b) => {
|
||||||
|
if (sort === '1') return byNewest(a, b)
|
||||||
|
if (sort === '2') return a.Name.localeCompare(b.Name)
|
||||||
|
return b.MemberCount - a.MemberCount || byNewest(a, b)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
Clubs: matched.slice(0, count).map(toDto),
|
||||||
|
ContinuationToken: null,
|
||||||
|
TotalClubs: matched.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player's "home club" — the one whose clubhouse they spawn into. It's a field
|
||||||
|
* on the *account* row (owned by the `auth` worker, on the same shared database, the
|
||||||
|
* way the `api` worker writes the account's profile image), not on the club: one
|
||||||
|
* home club per player.
|
||||||
|
*
|
||||||
|
* Returns null when they haven't set one, when the club is gone, or when it has no
|
||||||
|
* clubhouse room — a home club with nowhere to go isn't usable, and the reference
|
||||||
|
* 404s all three cases identically.
|
||||||
|
*/
|
||||||
|
export async function getHomeClub(db: D1Database, accountId: number): Promise<Club | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(
|
||||||
|
"SELECT json_extract(data, '$.homeClubId') AS clubId FROM account WHERE account_id = ?1"
|
||||||
|
)
|
||||||
|
.bind(accountId)
|
||||||
|
.first<{ clubId: number | null }>()
|
||||||
|
if (row?.clubId == null) return null
|
||||||
|
|
||||||
|
const club = await getClub(db, row.clubId)
|
||||||
|
// `== null` catches a club row that predates the field (undefined), not just an
|
||||||
|
// explicit null — either way it has no clubhouse to spawn into.
|
||||||
|
if (club === null || club.ClubhouseRoomId == null) return null
|
||||||
|
return club
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Point the player's home club at `clubId` (stored on their account row). */
|
||||||
|
export async function setHomeClub(
|
||||||
|
db: D1Database,
|
||||||
|
accountId: number,
|
||||||
|
clubId: number
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
// CAST to INTEGER — see syncMemberCount: a bound JS number lands as a REAL, so this
|
||||||
|
// would otherwise store `"homeClubId":7.0`.
|
||||||
|
.prepare(
|
||||||
|
"UPDATE account SET data = json_set(data, '$.homeClubId', CAST(?2 AS INTEGER)) WHERE account_id = ?1"
|
||||||
|
)
|
||||||
|
.bind(accountId, clubId)
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop the player's home club (the field is removed from their account row, not set
|
||||||
|
* to 0 — `getHomeClub` reads a missing field as "no home club"). Idempotent.
|
||||||
|
*/
|
||||||
|
export async function clearHomeClub(db: D1Database, accountId: number): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare("UPDATE account SET data = json_remove(data, '$.homeClubId') WHERE account_id = ?1")
|
||||||
|
.bind(accountId)
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A club membership row, as the members list serves it (mirror of the Go `ClubMember`). */
|
||||||
|
export interface ClubMember {
|
||||||
|
ClubMemberId: number
|
||||||
|
ClubId: number
|
||||||
|
AccountId: number
|
||||||
|
MembershipType: number
|
||||||
|
CreatedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A club's members (`/club/:id/members`). `membershipType` filters to exactly that
|
||||||
|
* tier when given — note it's an exact match, not a threshold, so `30` lists only
|
||||||
|
* co-owners (not the creator above them). `sortBy` picks the order: 1 = by account
|
||||||
|
* id, 2 = oldest membership first, anything else = the default, highest tier first
|
||||||
|
* then oldest. An unknown club has no members, so it's an empty list, not a 404.
|
||||||
|
*/
|
||||||
|
export async function getClubMembers(
|
||||||
|
db: D1Database,
|
||||||
|
clubId: number,
|
||||||
|
membershipType: number | undefined,
|
||||||
|
sortBy: string | undefined
|
||||||
|
): Promise<ClubMember[]> {
|
||||||
|
const order =
|
||||||
|
sortBy === '1'
|
||||||
|
? 'account_id ASC'
|
||||||
|
: sortBy === '2'
|
||||||
|
? 'created_at ASC'
|
||||||
|
: 'membership_type DESC, created_at ASC'
|
||||||
|
const filter = membershipType === undefined ? '' : 'AND membership_type = ?2'
|
||||||
|
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT club_member_id, club_id, account_id, membership_type, created_at
|
||||||
|
FROM club_member
|
||||||
|
WHERE club_id = ?1 ${filter}
|
||||||
|
ORDER BY ${order}`
|
||||||
|
)
|
||||||
|
.bind(...(membershipType === undefined ? [clubId] : [clubId, membershipType]))
|
||||||
|
.all<{
|
||||||
|
club_member_id: number
|
||||||
|
club_id: number
|
||||||
|
account_id: number
|
||||||
|
membership_type: number
|
||||||
|
created_at: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
return results.map((r) => ({
|
||||||
|
ClubMemberId: r.club_member_id,
|
||||||
|
ClubId: r.club_id,
|
||||||
|
AccountId: r.account_id,
|
||||||
|
MembershipType: r.membership_type,
|
||||||
|
CreatedAt: r.created_at,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fields `modifydetails` can change. Anything left undefined keeps its stored value. */
|
||||||
|
export interface ClubPatch {
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
category?: string
|
||||||
|
visibility?: number
|
||||||
|
joinability?: number
|
||||||
|
allowJuniors?: boolean
|
||||||
|
mainImageName?: string
|
||||||
|
minLevel?: number
|
||||||
|
/** Replaces the club's tags wholesale when present; absent leaves them alone. */
|
||||||
|
customTags?: string[]
|
||||||
|
/** The club's clubhouse room; `null` clears it (undefined leaves it alone). */
|
||||||
|
clubhouseRoomId?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply an edit to a club's details (`modifydetails`). Only the keys present on the
|
||||||
|
* patch change. Custom tags are replaced as a set — trimmed, de-duplicated
|
||||||
|
* case-insensitively, first spelling wins. Returns the updated club, or null when
|
||||||
|
* there's no such club.
|
||||||
|
*/
|
||||||
|
export async function updateClub(
|
||||||
|
db: D1Database,
|
||||||
|
clubId: number,
|
||||||
|
patch: ClubPatch
|
||||||
|
): Promise<Club | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT data FROM club WHERE club_id = ?1')
|
||||||
|
.bind(clubId)
|
||||||
|
.first<ClubRow>()
|
||||||
|
if (row === null) return null
|
||||||
|
const stored = JSON.parse(row.data) as StoredClub
|
||||||
|
|
||||||
|
const updated: StoredClub = {
|
||||||
|
...stored,
|
||||||
|
Name: patch.name ?? stored.Name,
|
||||||
|
Description: patch.description ?? stored.Description,
|
||||||
|
Category: patch.category ?? stored.Category,
|
||||||
|
Visibility: patch.visibility ?? stored.Visibility,
|
||||||
|
Joinability: patch.joinability ?? stored.Joinability,
|
||||||
|
AllowJuniors: patch.allowJuniors ?? stored.AllowJuniors,
|
||||||
|
MainImageName: patch.mainImageName ?? stored.MainImageName,
|
||||||
|
MinLevel: patch.minLevel ?? stored.MinLevel,
|
||||||
|
CustomTags: patch.customTags === undefined ? stored.CustomTags : dedupeTags(patch.customTags),
|
||||||
|
// `null` clears the clubhouse, so this can't collapse to `??`.
|
||||||
|
ClubhouseRoomId:
|
||||||
|
patch.clubhouseRoomId === undefined ? stored.ClubhouseRoomId : patch.clubhouseRoomId,
|
||||||
|
}
|
||||||
|
await db
|
||||||
|
.prepare('UPDATE club SET data = ?1 WHERE club_id = ?2')
|
||||||
|
.bind(JSON.stringify(updated), clubId)
|
||||||
|
.run()
|
||||||
|
return toDto(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trim, drop blanks, and de-duplicate tags case-insensitively (first spelling wins). */
|
||||||
|
function dedupeTags(tags: string[]): string[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const out: string[] = []
|
||||||
|
for (const raw of tags) {
|
||||||
|
const tag = raw.trim()
|
||||||
|
if (tag === '' || seen.has(tag.toLowerCase())) continue
|
||||||
|
seen.add(tag.toLowerCase())
|
||||||
|
out.push(tag)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A club's gallery image names, in order (stored on the blob; `[]` when it has none). */
|
||||||
|
export async function getClubAdditionalImages(db: D1Database, clubId: number): Promise<string[]> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT data FROM club WHERE club_id = ?1')
|
||||||
|
.bind(clubId)
|
||||||
|
.first<ClubRow>()
|
||||||
|
return row === null ? [] : ((JSON.parse(row.data) as StoredClub).AdditionalImages ?? [])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A club's gallery as the client reads it: the image record behind each name, in
|
||||||
|
* order. A name whose metadata row is missing falls back to a placeholder record so
|
||||||
|
* the picture still renders.
|
||||||
|
*/
|
||||||
|
export async function getClubGallery(db: D1Database, clubId: number): Promise<SavedImage[]> {
|
||||||
|
const names = await getClubAdditionalImages(db, clubId)
|
||||||
|
if (names.length === 0) return []
|
||||||
|
const records = await getSavedImagesByNames(db, names)
|
||||||
|
return names.map((name) => records.get(name) ?? placeholderSavedImage(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set (or remove, with an empty `imageName`) one of a club's gallery images. The list
|
||||||
|
* stays packed: removing an image shifts the ones after it up, and setting an index
|
||||||
|
* past the end appends rather than leaving a gap. Returns null when the club doesn't
|
||||||
|
* exist; the caller validates the index is in range.
|
||||||
|
*/
|
||||||
|
export async function setClubAdditionalImage(
|
||||||
|
db: D1Database,
|
||||||
|
clubId: number,
|
||||||
|
index: number,
|
||||||
|
imageName: string
|
||||||
|
): Promise<Club | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT data FROM club WHERE club_id = ?1')
|
||||||
|
.bind(clubId)
|
||||||
|
.first<ClubRow>()
|
||||||
|
if (row === null) return null
|
||||||
|
const stored = JSON.parse(row.data) as StoredClub
|
||||||
|
|
||||||
|
const images = [...(stored.AdditionalImages ?? [])]
|
||||||
|
if (imageName === '') {
|
||||||
|
// Removing past the end is a no-op, not an error: the image is already gone.
|
||||||
|
if (index < images.length) images.splice(index, 1)
|
||||||
|
} else if (index < images.length) {
|
||||||
|
images[index] = imageName
|
||||||
|
} else if (images.length < MAX_ADDITIONAL_IMAGES) {
|
||||||
|
images.push(imageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated: StoredClub = { ...stored, AdditionalImages: images }
|
||||||
|
await db
|
||||||
|
.prepare('UPDATE club SET data = ?1 WHERE club_id = ?2')
|
||||||
|
.bind(JSON.stringify(updated), clubId)
|
||||||
|
.run()
|
||||||
|
return toDto(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A club's custom tags (stored on the blob; empty when it has none). */
|
||||||
|
export async function getClubCustomTags(db: D1Database, clubId: number): Promise<string[]> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT data FROM club WHERE club_id = ?1')
|
||||||
|
.bind(clubId)
|
||||||
|
.first<ClubRow>()
|
||||||
|
return row === null ? [] : ((JSON.parse(row.data) as StoredClub).CustomTags ?? [])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Look up a single club by its ClubId. */
|
||||||
|
export async function getClub(db: D1Database, clubId: number): Promise<Club | null> {
|
||||||
|
return parseOne(
|
||||||
|
await db.prepare('SELECT data FROM club WHERE club_id = ?1').bind(clubId).first<ClubRow>()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The two club fields other workers read without wanting the whole DTO. */
|
||||||
export interface ClubSummary {
|
export interface ClubSummary {
|
||||||
clubId: number
|
clubId: number
|
||||||
name: string
|
name: string
|
||||||
@@ -27,7 +757,11 @@ export interface ClubSummary {
|
|||||||
clubhouseRoomId: number | null
|
clubhouseRoomId: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Look up a club's name and clubhouse room. Null when there's no such club. */
|
/**
|
||||||
|
* Look up just a club's name and clubhouse room. Null when there's no such club.
|
||||||
|
* A narrow projection rather than {@link getClub} — `match` only needs the room to
|
||||||
|
* send a player to, and shouldn't parse (or depend on) the whole blob to get it.
|
||||||
|
*/
|
||||||
export async function getClubSummary(db: D1Database, clubId: number): Promise<ClubSummary | null> {
|
export async function getClubSummary(db: D1Database, clubId: number): Promise<ClubSummary | null> {
|
||||||
const row = await db
|
const row = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -41,17 +775,87 @@ export async function getClubSummary(db: D1Database, clubId: number): Promise<Cl
|
|||||||
return { clubId, name: row.name ?? '', clubhouseRoomId: row.clubhouseRoomId }
|
return { clubId, name: row.name ?? '', clubhouseRoomId: row.clubhouseRoomId }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A player's membership type in a club (0 = no row, i.e. not a member). */
|
/**
|
||||||
export async function getClubMembership(
|
* Delete a club and everything hanging off it — its memberships and announcements —
|
||||||
db: D1Database,
|
* and clear it from the home club of anyone who'd set it. Returns false when there
|
||||||
clubId: number,
|
* was no such club. Batched so a half-deleted club can't be left behind.
|
||||||
accountId: number
|
*/
|
||||||
): Promise<number> {
|
export async function deleteClub(db: D1Database, clubId: number): Promise<boolean> {
|
||||||
|
if ((await getClub(db, clubId)) === null) return false
|
||||||
|
await db.batch([
|
||||||
|
db.prepare('DELETE FROM club_member WHERE club_id = ?1').bind(clubId),
|
||||||
|
db.prepare('DELETE FROM club_announcement WHERE club_id = ?1').bind(clubId),
|
||||||
|
// The account table belongs to the auth worker; a dangling homeClubId already
|
||||||
|
// reads as "no home club" (getHomeClub), but leaving it would point at whatever
|
||||||
|
// club later reuses the id.
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE account SET data = json_remove(data, '$.homeClubId')
|
||||||
|
WHERE json_extract(data, '$.homeClubId') = ?1`
|
||||||
|
)
|
||||||
|
.bind(clubId),
|
||||||
|
db.prepare('DELETE FROM club WHERE club_id = ?1').bind(clubId),
|
||||||
|
])
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscription clubs (`ClubType` 1) are a creator's paid-subscriber club, not a
|
||||||
|
* club you browse or list among your own — they're excluded from the "my clubs"
|
||||||
|
* lists (the client reaches them through the `/subscription/*` endpoints instead).
|
||||||
|
*/
|
||||||
|
const SUBSCRIPTION_CLUB_TYPE = 1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many clubs an account has made, for the per-account club cap. Subscription
|
||||||
|
* clubs don't count — they're provisioned for a creator's subscribers rather than
|
||||||
|
* made by hand, so they shouldn't eat a slot.
|
||||||
|
*/
|
||||||
|
export async function countClubsByCreator(db: D1Database, accountId: number): Promise<number> {
|
||||||
const row = await db
|
const row = await db
|
||||||
.prepare('SELECT membership_type AS t FROM club_member WHERE club_id = ?1 AND account_id = ?2')
|
.prepare(
|
||||||
.bind(clubId, accountId)
|
`SELECT COUNT(*) AS n FROM club
|
||||||
.first<{ t: number }>()
|
WHERE creator_account_id = ?1
|
||||||
return row?.t ?? CLUB_MEMBERSHIP_NONE
|
AND json_extract(data, '$.ClubType') != ?2`
|
||||||
|
)
|
||||||
|
.bind(accountId, SUBSCRIPTION_CLUB_TYPE)
|
||||||
|
.first<{ n: number }>()
|
||||||
|
return row?.n ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All clubs created by an account (GetMyCreatedClubs), oldest first. */
|
||||||
|
export async function getClubsByCreator(db: D1Database, accountId: number): Promise<Club[]> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT data FROM club
|
||||||
|
WHERE creator_account_id = ?1
|
||||||
|
AND json_extract(data, '$.ClubType') != ?2
|
||||||
|
ORDER BY json_extract(data, '$.CreatedAt') ASC`
|
||||||
|
)
|
||||||
|
.bind(accountId, SUBSCRIPTION_CLUB_TYPE)
|
||||||
|
.all<ClubRow>()
|
||||||
|
return parseAll(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All clubs an account is an actual member of (GetMyMembershipClubs), oldest club
|
||||||
|
* first. Only memberships at/above `Member` count — pending requests, denied
|
||||||
|
* requests, and bans are excluded. Joins `club_member` to `club`, so a membership
|
||||||
|
* whose club is gone is simply absent.
|
||||||
|
*/
|
||||||
|
export async function getClubsByMember(db: D1Database, accountId: number): Promise<Club[]> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT c.data AS data
|
||||||
|
FROM club_member m
|
||||||
|
JOIN club c ON c.club_id = m.club_id
|
||||||
|
WHERE m.account_id = ?1 AND m.membership_type >= ?2
|
||||||
|
AND json_extract(c.data, '$.ClubType') != ?3
|
||||||
|
ORDER BY json_extract(c.data, '$.CreatedAt') ASC`
|
||||||
|
)
|
||||||
|
.bind(accountId, MEMBER_THRESHOLD, SUBSCRIPTION_CLUB_TYPE)
|
||||||
|
.all<ClubRow>()
|
||||||
|
return parseAll(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether an account is an actual member of a club (Member tier or above). */
|
/** Whether an account is an actual member of a club (Member tier or above). */
|
||||||
@@ -60,5 +864,130 @@ export async function isClubMember(
|
|||||||
clubId: number,
|
clubId: number,
|
||||||
accountId: number
|
accountId: number
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
return (await getClubMembership(db, clubId, accountId)) >= CLUB_MEMBERSHIP_MEMBER
|
return (await getMembership(db, clubId, accountId)) >= MEMBER_THRESHOLD
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Have `accountId` join a club. On an Open club they become a `Member` immediately;
|
||||||
|
* on an InviteOnly/AskToJoin club the join is recorded as `PendingRequested` (an
|
||||||
|
* approval flow, not yet a member). Idempotent for anyone already a member, and a
|
||||||
|
* no-op for a banned account. Returns the club with its refreshed MemberCount, or
|
||||||
|
* null when the club doesn't exist.
|
||||||
|
*/
|
||||||
|
export async function joinClub(
|
||||||
|
db: D1Database,
|
||||||
|
clubId: number,
|
||||||
|
accountId: number
|
||||||
|
): Promise<Club | null> {
|
||||||
|
const club = await getClub(db, clubId)
|
||||||
|
if (!club) return null
|
||||||
|
|
||||||
|
const current = await getMembership(db, clubId, accountId)
|
||||||
|
// A ban can't be shed by re-joining, and an existing member/pending stays as-is.
|
||||||
|
if (current === ClubMembershipType.Banned || current >= MEMBER_THRESHOLD) {
|
||||||
|
return club
|
||||||
|
}
|
||||||
|
const next =
|
||||||
|
club.Joinability === ClubJoinability.Open
|
||||||
|
? ClubMembershipType.Member
|
||||||
|
: ClubMembershipType.PendingRequested
|
||||||
|
await setMembership(db, clubId, accountId, next)
|
||||||
|
|
||||||
|
const count = await syncMemberCount(db, clubId)
|
||||||
|
return { ...club, MemberCount: count }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a request to join resolved. `joined` is an Open club (no approval needed),
|
||||||
|
* `requested` an AskToJoin club (now PendingRequested), `alreadyPending` a repeat
|
||||||
|
* request, `alreadyMember` someone who's already in. `inviteOnly` and `banned` are
|
||||||
|
* refusals — the caller can't get in this way.
|
||||||
|
*/
|
||||||
|
export type JoinRequestResult =
|
||||||
|
'joined' | 'requested' | 'alreadyPending' | 'alreadyMember' | 'inviteOnly' | 'banned'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask to join a club. Unlike `joinClub` this honours the club's Joinability strictly:
|
||||||
|
* an InviteOnly club can only be entered through an invite, so a request is refused
|
||||||
|
* rather than parked as pending. Returns the outcome plus the club with its refreshed
|
||||||
|
* MemberCount, or null when the club doesn't exist.
|
||||||
|
*/
|
||||||
|
export async function requestToJoinClub(
|
||||||
|
db: D1Database,
|
||||||
|
clubId: number,
|
||||||
|
accountId: number
|
||||||
|
): Promise<{ result: JoinRequestResult; club: Club } | null> {
|
||||||
|
const club = await getClub(db, clubId)
|
||||||
|
if (!club) return null
|
||||||
|
|
||||||
|
const current = await getMembership(db, clubId, accountId)
|
||||||
|
// A ban can't be shed by asking again, and existing members/requests stay as-is.
|
||||||
|
if (current === ClubMembershipType.Banned) return { result: 'banned', club }
|
||||||
|
if (current >= MEMBER_THRESHOLD) return { result: 'alreadyMember', club }
|
||||||
|
if (current === ClubMembershipType.PendingRequested) return { result: 'alreadyPending', club }
|
||||||
|
|
||||||
|
if (club.Joinability === ClubJoinability.InviteOnly) return { result: 'inviteOnly', club }
|
||||||
|
|
||||||
|
const open = club.Joinability === ClubJoinability.Open
|
||||||
|
await setMembership(
|
||||||
|
db,
|
||||||
|
clubId,
|
||||||
|
accountId,
|
||||||
|
open ? ClubMembershipType.Member : ClubMembershipType.PendingRequested
|
||||||
|
)
|
||||||
|
|
||||||
|
const count = await syncMemberCount(db, clubId)
|
||||||
|
return { result: open ? 'joined' : 'requested', club: { ...club, MemberCount: count } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove `accountId`'s membership of a club (idempotent). A ban is preserved — you
|
||||||
|
* can't clear it by leaving — but any member/pending row is dropped. Returns the
|
||||||
|
* outcome plus the club with its refreshed MemberCount, or null when the club doesn't
|
||||||
|
* exist. The club itself is left in place even when the last member leaves.
|
||||||
|
*
|
||||||
|
* The creator can't leave: a club with no owner has no one who can administer it, and
|
||||||
|
* there's no ownership transfer, so they have to delete the club instead. `creator`
|
||||||
|
* reports that refusal, with the club unchanged.
|
||||||
|
*/
|
||||||
|
export async function leaveClub(
|
||||||
|
db: D1Database,
|
||||||
|
clubId: number,
|
||||||
|
accountId: number
|
||||||
|
): Promise<{ result: 'left' | 'creator'; club: Club } | null> {
|
||||||
|
const club = await getClub(db, clubId)
|
||||||
|
if (!club) return null
|
||||||
|
|
||||||
|
const current = await getMembership(db, clubId, accountId)
|
||||||
|
if (current === ClubMembershipType.Creator) return { result: 'creator', club }
|
||||||
|
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'DELETE FROM club_member WHERE club_id = ?1 AND account_id = ?2 AND membership_type <> ?3'
|
||||||
|
)
|
||||||
|
.bind(clubId, accountId, ClubMembershipType.Banned)
|
||||||
|
.run()
|
||||||
|
const count = await syncMemberCount(db, clubId)
|
||||||
|
return { result: 'left', club: { ...club, MemberCount: count } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set an account's membership tier in a club — the invite / role-assignment write
|
||||||
|
* behind `PUT /club/:id/members/invite`. Upserts the `club_member` row to
|
||||||
|
* `membershipType` (adding the account when it wasn't a member, and overriding a prior
|
||||||
|
* tier or ban), then refreshes the club's MemberCount. Returns the club with its fresh
|
||||||
|
* count, or null when the club is gone. The caller is responsible for checking that the
|
||||||
|
* tier is one it may grant and that the target isn't the club's Creator.
|
||||||
|
*/
|
||||||
|
export async function setMemberType(
|
||||||
|
db: D1Database,
|
||||||
|
clubId: number,
|
||||||
|
accountId: number,
|
||||||
|
membershipType: ClubMembershipType
|
||||||
|
): Promise<Club | null> {
|
||||||
|
const club = await getClub(db, clubId)
|
||||||
|
if (!club) return null
|
||||||
|
await setMembership(db, clubId, accountId, membershipType)
|
||||||
|
const count = await syncMemberCount(db, clubId)
|
||||||
|
return { ...club, MemberCount: count }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,64 @@
|
|||||||
/**
|
/**
|
||||||
* Cross-worker *reads* of the image-metadata table. The `img` worker owns the schema
|
* Image-metadata storage on the shared `recflare` D1 database. Each image is a
|
||||||
* and the `api` worker handles uploads and writes (see apps/api/src/images-db.ts);
|
* single JSON blob in the `data` column; queryable fields (Id, ImageName,
|
||||||
* this is the read-only view other workers need when they store an image *name* but
|
* PlayerId, RoomId) are SQLite generated (virtual) columns extracted from that
|
||||||
* have to serve the client the whole image record — the client deserializes those
|
* JSON — the same JSON-blob pattern the rooms/accounts tables use.
|
||||||
* into its `SavedImage` type, not into strings.
|
|
||||||
*
|
*
|
||||||
* Right now that's `clubs`, for a club's gallery images.
|
* The `img` worker owns the schema/migrations (migrations/0001_image.sql and
|
||||||
|
* 0002_image_interaction.sql, applied with its own `migrations_table` so they don't
|
||||||
|
* clash with the other workers' migrations on the shared database); the `api` worker
|
||||||
|
* handles uploads, cheers and the photo feeds. Other workers (`clubs`, for a club's
|
||||||
|
* gallery) only read: they store an image *name* but have to serve the client the
|
||||||
|
* whole image record, since the client deserializes those into its `SavedImage`
|
||||||
|
* type, not into strings.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** Schema DDL (mirror of the `img` worker's migrations, sans any seed rows). */
|
||||||
|
export const IMAGE_SCHEMA_DDL: string[] = [
|
||||||
|
`CREATE TABLE IF NOT EXISTS image (
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Id')) VIRTUAL,
|
||||||
|
image_name TEXT GENERATED ALWAYS AS (json_extract(data, '$.ImageName')) VIRTUAL,
|
||||||
|
player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.PlayerId')) VIRTUAL,
|
||||||
|
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL
|
||||||
|
)`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_image_id ON image (id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_image_image_name ON image (image_name)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_image_player_id ON image (player_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_image_room_id ON image (room_id)`,
|
||||||
|
// A player's interaction with a saved image — one row per (player, image). Only
|
||||||
|
// `cheered` for now; named generically so other per-user interactions (e.g.
|
||||||
|
// favorited) can be added as columns. The `api` worker writes it (cheer endpoints)
|
||||||
|
// and keeps the image's denormalized `CheerCount` in sync from it.
|
||||||
|
`CREATE TABLE IF NOT EXISTS image_interaction (
|
||||||
|
player_id INTEGER NOT NULL,
|
||||||
|
saved_image_id INTEGER NOT NULL,
|
||||||
|
cheered INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT,
|
||||||
|
PRIMARY KEY (player_id, saved_image_id)
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_image_interaction_image ON image_interaction (saved_image_id)`,
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saved-image categories from the reference's `SavedImageType` enum — the value of a
|
||||||
|
* stored image's `Type` (and the client's `imgMeta.savedImageType` on upload). Lives
|
||||||
|
* here in the image data layer so both the upload route and the slideshow query share
|
||||||
|
* one definition.
|
||||||
|
*/
|
||||||
|
export const SavedImageType = {
|
||||||
|
None: 0,
|
||||||
|
ShareCamera: 1,
|
||||||
|
OutfitThumbnail: 2,
|
||||||
|
RoomThumbnail: 3,
|
||||||
|
ProfileThumbnail: 4,
|
||||||
|
InventionThumbnail: 5,
|
||||||
|
} as const
|
||||||
|
|
||||||
/** A stored image record (the client-facing SavedImage shape). */
|
/** A stored image record (the client-facing SavedImage shape). */
|
||||||
export interface SavedImage {
|
export interface SavedImage {
|
||||||
Id: number
|
Id: number
|
||||||
|
/** A {@link SavedImageType} value. */
|
||||||
Type: number
|
Type: number
|
||||||
Accessibility: number
|
Accessibility: number
|
||||||
AccessibilityLocked: boolean
|
AccessibilityLocked: boolean
|
||||||
@@ -25,10 +73,126 @@ export interface SavedImage {
|
|||||||
CommentCount: number
|
CommentCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ImageRow {
|
||||||
|
data: string
|
||||||
|
}
|
||||||
|
|
||||||
/** Build the `?1,?2,…` placeholder list for an `IN (…)` clause. */
|
/** Build the `?1,?2,…` placeholder list for an `IN (…)` clause. */
|
||||||
const placeholders = (n: number): string =>
|
const placeholders = (n: number): string =>
|
||||||
Array.from({ length: n }, (_, i) => `?${i + 1}`).join(',')
|
Array.from({ length: n }, (_, i) => `?${i + 1}`).join(',')
|
||||||
|
|
||||||
|
/** Fields supplied at upload time (from `imgMeta`); everything else defaults. */
|
||||||
|
export interface NewImage {
|
||||||
|
imageName: string
|
||||||
|
playerId: number
|
||||||
|
type?: number
|
||||||
|
accessibility?: number
|
||||||
|
roomId?: number | null
|
||||||
|
description?: string | null
|
||||||
|
taggedPlayerIds?: number[]
|
||||||
|
playerEventId?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert a new image record for an upload, returning the stored row. */
|
||||||
|
export async function createImage(db: D1Database, input: NewImage): Promise<SavedImage> {
|
||||||
|
// Sequential id: one past the current max (the table starts empty).
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT COALESCE(MAX(id), 0) + 1 AS next FROM image')
|
||||||
|
.first<{ next: number }>()
|
||||||
|
const image: SavedImage = {
|
||||||
|
Id: row?.next ?? 1,
|
||||||
|
Type: input.type ?? 1,
|
||||||
|
Accessibility: input.accessibility ?? 1,
|
||||||
|
AccessibilityLocked: false,
|
||||||
|
ImageName: input.imageName,
|
||||||
|
Description: input.description ?? null,
|
||||||
|
PlayerId: input.playerId,
|
||||||
|
TaggedPlayerIds: input.taggedPlayerIds ?? [],
|
||||||
|
RoomId: input.roomId ?? null,
|
||||||
|
PlayerEventId: input.playerEventId ?? null,
|
||||||
|
CreatedAt: new Date().toISOString(),
|
||||||
|
CheerCount: 0,
|
||||||
|
CommentCount: 0,
|
||||||
|
}
|
||||||
|
await db.prepare('INSERT INTO image (data) VALUES (?1)').bind(JSON.stringify(image)).run()
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recompute an image's `CheerCount` from the `image_interaction` rows and write it
|
||||||
|
* back into the blob (nothing reads a generated column for it, but the client-facing
|
||||||
|
* blob must stay accurate). CAST to INTEGER: D1 binds a JS number as a SQLite REAL,
|
||||||
|
* which json_set would otherwise store as `"CheerCount":3.0`. Returns the fresh count.
|
||||||
|
*/
|
||||||
|
async function syncImageCheerCount(db: D1Database, savedImageId: number): Promise<number> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(
|
||||||
|
'SELECT COUNT(*) AS n FROM image_interaction WHERE saved_image_id = ?1 AND cheered = 1'
|
||||||
|
)
|
||||||
|
.bind(savedImageId)
|
||||||
|
.first<{ n: number }>()
|
||||||
|
const count = row?.n ?? 0
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
"UPDATE image SET data = json_set(data, '$.CheerCount', CAST(?2 AS INTEGER)) WHERE id = ?1"
|
||||||
|
)
|
||||||
|
.bind(savedImageId, count)
|
||||||
|
.run()
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set (or clear) a player's cheer on a saved image — upserts the one row per
|
||||||
|
* (player, image) — then resyncs the image's `CheerCount`. Idempotent: re-cheering
|
||||||
|
* an already-cheered image is a no-op on the count.
|
||||||
|
*/
|
||||||
|
export async function setImageCheer(
|
||||||
|
db: D1Database,
|
||||||
|
playerId: number,
|
||||||
|
savedImageId: number,
|
||||||
|
cheer: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO image_interaction (player_id, saved_image_id, cheered, created_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)
|
||||||
|
ON CONFLICT(player_id, saved_image_id) DO UPDATE SET cheered = ?3`
|
||||||
|
)
|
||||||
|
.bind(playerId, savedImageId, cheer ? 1 : 0, new Date().toISOString())
|
||||||
|
.run()
|
||||||
|
await syncImageCheerCount(db, savedImageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which of the given saved-image ids the player has cheered — the set of cheered
|
||||||
|
* ids (a subset of `ids`). Backs the bulk `cheered` lookup. Empty input → empty set.
|
||||||
|
*/
|
||||||
|
export async function getCheeredImageIds(
|
||||||
|
db: D1Database,
|
||||||
|
playerId: number,
|
||||||
|
ids: number[]
|
||||||
|
): Promise<Set<number>> {
|
||||||
|
if (ids.length === 0) return new Set()
|
||||||
|
const inList = ids.map((_, i) => `?${i + 2}`).join(',')
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT saved_image_id AS id FROM image_interaction
|
||||||
|
WHERE player_id = ?1 AND cheered = 1 AND saved_image_id IN (${inList})`
|
||||||
|
)
|
||||||
|
.bind(playerId, ...ids)
|
||||||
|
.all<{ id: number }>()
|
||||||
|
return new Set(results.map((r) => r.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Look up an image record by its ImageName (the R2 key / filename), or null. */
|
||||||
|
export async function getImageByName(db: D1Database, name: string): Promise<SavedImage | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT data FROM image WHERE image_name = ?1')
|
||||||
|
.bind(name)
|
||||||
|
.first<ImageRow>()
|
||||||
|
return row ? (JSON.parse(row.data) as SavedImage) : null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Look up image records by name (the R2 key), returned keyed by ImageName. One query
|
* Look up image records by name (the R2 key), returned keyed by ImageName. One query
|
||||||
* for the whole set; names with no record are simply absent from the map.
|
* for the whole set; names with no record are simply absent from the map.
|
||||||
@@ -41,7 +205,7 @@ export async function getSavedImagesByNames(
|
|||||||
const { results } = await db
|
const { results } = await db
|
||||||
.prepare(`SELECT data FROM image WHERE image_name IN (${placeholders(names.length)})`)
|
.prepare(`SELECT data FROM image WHERE image_name IN (${placeholders(names.length)})`)
|
||||||
.bind(...names)
|
.bind(...names)
|
||||||
.all<{ data: string }>()
|
.all<ImageRow>()
|
||||||
return new Map(
|
return new Map(
|
||||||
results.map((r) => {
|
results.map((r) => {
|
||||||
const image = JSON.parse(r.data) as SavedImage
|
const image = JSON.parse(r.data) as SavedImage
|
||||||
@@ -72,3 +236,234 @@ export function placeholderSavedImage(imageName: string): SavedImage {
|
|||||||
CommentCount: 0,
|
CommentCount: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an image's metadata row plus any per-player interactions (cheers) recorded
|
||||||
|
* against it, in one batch — the row keyed by ImageName (the R2 key), its interactions
|
||||||
|
* by the image's `Id`. Authorization and removing the object from R2 are the caller's
|
||||||
|
* responsibility (see the deletesaved route).
|
||||||
|
*/
|
||||||
|
export async function deleteImage(db: D1Database, image: SavedImage): Promise<void> {
|
||||||
|
await db.batch([
|
||||||
|
db.prepare('DELETE FROM image WHERE image_name = ?1').bind(image.ImageName),
|
||||||
|
db.prepare('DELETE FROM image_interaction WHERE saved_image_id = ?1').bind(image.Id),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public images taken in a room, for the room's photo feed. Only publicly
|
||||||
|
* accessible images (Accessibility === 1) are returned. `filter` narrows by
|
||||||
|
* `SavedImageType` (0 = all types); `sort` orders the feed — `1` puts the most
|
||||||
|
* cheered first (ties broken by newest), anything else is newest-first. Paginated
|
||||||
|
* via skip/take; returns a bare array of SavedImage. The per-room set is small, so
|
||||||
|
* the room_id index does the lookup and filtering/sorting happens in memory.
|
||||||
|
*
|
||||||
|
* NOTE: the exact `sort`/`filter` enum values are best guesses — the client sends
|
||||||
|
* `sort=1&filter=1`, and this treats them as most-cheered / ShareCamera.
|
||||||
|
*/
|
||||||
|
export async function getImagesByRoom(
|
||||||
|
db: D1Database,
|
||||||
|
roomId: number,
|
||||||
|
sort: number,
|
||||||
|
filter: number,
|
||||||
|
skip: number,
|
||||||
|
take: number
|
||||||
|
): Promise<SavedImage[]> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare('SELECT data FROM image WHERE room_id = ?1')
|
||||||
|
.bind(roomId)
|
||||||
|
.all<ImageRow>()
|
||||||
|
let images = results
|
||||||
|
.map((r) => JSON.parse(r.data) as SavedImage)
|
||||||
|
.filter((img) => img.Accessibility === 1)
|
||||||
|
|
||||||
|
if (filter > 0) images = images.filter((img) => img.Type === filter)
|
||||||
|
|
||||||
|
images.sort(sort === 1 ? (a, b) => b.CheerCount - a.CheerCount || newestFirst(a, b) : newestFirst)
|
||||||
|
|
||||||
|
return images.slice(skip, skip + take)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Newest-first order: most recent CreatedAt, ties broken by higher Id. */
|
||||||
|
const newestFirst = (a: SavedImage, b: SavedImage) =>
|
||||||
|
b.CreatedAt.localeCompare(a.CreatedAt) || b.Id - a.Id
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public images a player has taken — their photo list, newest first.
|
||||||
|
* Paginated via skip/take; returns a bare array of SavedImage. Uses the
|
||||||
|
* player_id index; the per-player set is small, so filtering/sorting is in memory.
|
||||||
|
*/
|
||||||
|
export async function getImagesByPlayer(
|
||||||
|
db: D1Database,
|
||||||
|
playerId: number,
|
||||||
|
sort: number,
|
||||||
|
skip: number,
|
||||||
|
take: number
|
||||||
|
): Promise<SavedImage[]> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare('SELECT data FROM image WHERE player_id = ?1')
|
||||||
|
.bind(playerId)
|
||||||
|
.all<ImageRow>()
|
||||||
|
return results
|
||||||
|
.map((r) => JSON.parse(r.data) as SavedImage)
|
||||||
|
.filter((img) => img.Accessibility === 1)
|
||||||
|
.sort(sort === 1 ? (a, b) => b.CheerCount - a.CheerCount || newestFirst(a, b) : newestFirst)
|
||||||
|
.slice(skip, skip + take)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The client-facing projection of a saved image for the player photo lists (the
|
||||||
|
* reference's `ImagesPlayer`). Same data as the stored record, but the id and type
|
||||||
|
* are renamed — `Id` → `SavedImageId`, `Type` → `SavedImageType` — and the tagged
|
||||||
|
* player ids aren't part of it. The client deserializes into this shape, so a raw
|
||||||
|
* SavedImage leaves it without an image id and its thumbnails come up blank.
|
||||||
|
*/
|
||||||
|
export interface ImagesPlayer {
|
||||||
|
Accessibility: number
|
||||||
|
AccessibilityLocked: boolean
|
||||||
|
CheerCount: number
|
||||||
|
CommentCount: number
|
||||||
|
CreatedAt: string
|
||||||
|
Description: string | null
|
||||||
|
ImageName: string
|
||||||
|
PlayerEventId: number | null
|
||||||
|
PlayerId: number
|
||||||
|
RoomId: number | null
|
||||||
|
SavedImageId: number
|
||||||
|
SavedImageType: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project a stored image to the client's ImagesPlayer shape. */
|
||||||
|
export function toImagesPlayer(img: SavedImage): ImagesPlayer {
|
||||||
|
return {
|
||||||
|
Accessibility: img.Accessibility,
|
||||||
|
AccessibilityLocked: img.AccessibilityLocked,
|
||||||
|
CheerCount: img.CheerCount,
|
||||||
|
CommentCount: img.CommentCount,
|
||||||
|
CreatedAt: img.CreatedAt,
|
||||||
|
Description: img.Description,
|
||||||
|
ImageName: img.ImageName,
|
||||||
|
PlayerEventId: img.PlayerEventId,
|
||||||
|
PlayerId: img.PlayerId,
|
||||||
|
RoomId: img.RoomId,
|
||||||
|
SavedImageId: img.Id,
|
||||||
|
SavedImageType: img.Type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many recent images the slideshow feed returns when the caller doesn't say. */
|
||||||
|
export const SLIDESHOW_LIMIT = 10
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The most a caller can ask the slideshow feed for. The endpoint is public and
|
||||||
|
* unauthenticated, so the cap is what keeps an arbitrary `take` from turning into a
|
||||||
|
* scan of the whole image table plus the two batched joins behind it.
|
||||||
|
*/
|
||||||
|
export const SLIDESHOW_MAX_LIMIT = 100
|
||||||
|
|
||||||
|
/** The slideshow projection of an image — creator username + room name joined in. */
|
||||||
|
export interface SlideshowImage {
|
||||||
|
SavedImageId: number
|
||||||
|
ImageName: string
|
||||||
|
Username: string
|
||||||
|
RoomName: string | null
|
||||||
|
RoomId: number | null
|
||||||
|
SavedImageType: number
|
||||||
|
PlayerEventId: number | null
|
||||||
|
Accessibility: number
|
||||||
|
PlayerIds: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map account ids → username, resolved from the shared accounts table. */
|
||||||
|
async function getUsernames(db: D1Database, ids: number[]): Promise<Map<number, string>> {
|
||||||
|
if (ids.length === 0) return new Map()
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT account_id AS id, json_extract(data, '$.username') AS username
|
||||||
|
FROM account WHERE account_id IN (${placeholders(ids.length)})`
|
||||||
|
)
|
||||||
|
.bind(...ids)
|
||||||
|
.all<{ id: number; username: string }>()
|
||||||
|
return new Map(results.map((r) => [r.id, r.username]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map room ids → room name, resolved from the shared rooms table. */
|
||||||
|
async function getRoomNames(db: D1Database, ids: number[]): Promise<Map<number, string>> {
|
||||||
|
if (ids.length === 0) return new Map()
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT room_id AS id, json_extract(data, '$.Name') AS name
|
||||||
|
FROM room WHERE room_id IN (${placeholders(ids.length)})`
|
||||||
|
)
|
||||||
|
.bind(...ids)
|
||||||
|
.all<{ id: number; name: string }>()
|
||||||
|
return new Map(results.map((r) => [r.id, r.name]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The global slideshow feed — the most recent publicly-listable ShareCamera photos
|
||||||
|
* across all rooms (Accessibility 0 or 1, Type 1), newest first, capped at `limit`.
|
||||||
|
* Only ShareCamera images are surfaced (not room/profile/invention thumbnails). Each
|
||||||
|
* row is joined to its creator's username and (if any) its room's name. Returns the
|
||||||
|
* projected SlideshowImage shape. Usernames/room names are resolved in two batched
|
||||||
|
* lookups to avoid an N+1 across the (at most `limit`) images.
|
||||||
|
*/
|
||||||
|
export async function getSlideshowImages(
|
||||||
|
db: D1Database,
|
||||||
|
limit = SLIDESHOW_LIMIT
|
||||||
|
): Promise<SlideshowImage[]> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT data FROM image
|
||||||
|
WHERE json_extract(data, '$.Accessibility') IN (0, 1)
|
||||||
|
AND json_extract(data, '$.Type') = ?1
|
||||||
|
ORDER BY id DESC LIMIT ?2`
|
||||||
|
)
|
||||||
|
.bind(SavedImageType.ShareCamera, limit)
|
||||||
|
.all<ImageRow>()
|
||||||
|
const images = results.map((r) => JSON.parse(r.data) as SavedImage)
|
||||||
|
|
||||||
|
const roomIds = [...new Set(images.map((i) => i.RoomId).filter((v): v is number => v != null))]
|
||||||
|
const usernames = await getUsernames(db, [...new Set(images.map((i) => i.PlayerId))])
|
||||||
|
const roomNames = await getRoomNames(db, roomIds)
|
||||||
|
|
||||||
|
return images.map((img) => ({
|
||||||
|
SavedImageId: img.Id,
|
||||||
|
ImageName: img.ImageName,
|
||||||
|
// Fall back to the synthesized "Player<id>" name for accounts not in the table.
|
||||||
|
Username: usernames.get(img.PlayerId) ?? `Player${img.PlayerId}`,
|
||||||
|
RoomName: img.RoomId != null ? (roomNames.get(img.RoomId) ?? null) : null,
|
||||||
|
RoomId: img.RoomId,
|
||||||
|
SavedImageType: img.Type,
|
||||||
|
PlayerEventId: img.PlayerEventId,
|
||||||
|
Accessibility: img.Accessibility,
|
||||||
|
PlayerIds: img.TaggedPlayerIds,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A player's photo feed — the public images they took plus the ones they're
|
||||||
|
* tagged in (TaggedPlayerIds). Newest first, paginated via skip/take; returns a
|
||||||
|
* bare array of SavedImage. The tagged-in match uses json_each over the stored
|
||||||
|
* TaggedPlayerIds array (there's no index for it).
|
||||||
|
*/
|
||||||
|
export async function getPlayerFeed(
|
||||||
|
db: D1Database,
|
||||||
|
playerId: number,
|
||||||
|
skip: number,
|
||||||
|
take: number
|
||||||
|
): Promise<SavedImage[]> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT data FROM image
|
||||||
|
WHERE player_id = ?1
|
||||||
|
OR EXISTS (SELECT 1 FROM json_each(image.data, '$.TaggedPlayerIds') WHERE value = ?1)`
|
||||||
|
)
|
||||||
|
.bind(playerId)
|
||||||
|
.all<ImageRow>()
|
||||||
|
return results
|
||||||
|
.map((r) => JSON.parse(r.data) as SavedImage)
|
||||||
|
.filter((img) => img.Accessibility === 1)
|
||||||
|
.sort(newestFirst)
|
||||||
|
.slice(skip, skip + take)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,35 +1,179 @@
|
|||||||
/**
|
/**
|
||||||
* Read-only access to the friendship graph on the shared `recflare` D1 database.
|
* Friendship / relationship storage on the shared `recflare` D1 database.
|
||||||
*
|
*
|
||||||
* The `relationship` table's schema and every mutation are owned by the `api` worker
|
* Unlike the JSON-blob tables in this database (rooms/accounts/image), a
|
||||||
* (apps/api/src/relationships-db.ts, migrations/0001_relationship.sql). This module is
|
* relationship is genuinely columnar, so it gets a normal relational table
|
||||||
* the shared *reader* other workers need: the `match` worker looks up a player's friends
|
* (mirroring the Go/GORM `Relationship` model). Exactly ONE row exists per
|
||||||
* to push a presence update to them when the player changes rooms. It only SELECTs the
|
* unordered pair of players: the player who initiated is the `requester`, the
|
||||||
* three columns that identify a friendship (requester/target/type), so it stays
|
* other is the `target`. `relationship_type` is stored from the requester's
|
||||||
* decoupled from the favorited/ignored/muted flag columns api layers on top.
|
* point of view; when we project the row for the *target* we flip
|
||||||
|
* Sent↔Received (Friend/None are symmetric).
|
||||||
|
*
|
||||||
|
* The `api` worker owns the schema/migration (migrations/0001_relationship.sql,
|
||||||
|
* applied under its own `migrations_table` so it doesn't clash with the other
|
||||||
|
* workers' migrations that share the database) and every mutation here is reached
|
||||||
|
* through its social routes. The reads are shared: `match` pushes a presence update
|
||||||
|
* to a player's friends when they change rooms, and both `match` and `rooms` gate
|
||||||
|
* follow-a-friend on {@link areFriends}.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/** Relationship state from the perspective of the player asking (mirrors the reference). */
|
||||||
* `relationship_type` for a mutual friendship — mirror of api's `RelationshipType.Friend`.
|
export enum RelationshipType {
|
||||||
* Pending requests (1 sent / 2 received) and bare ignore/mute rows (0) are not friends.
|
None = 0,
|
||||||
*/
|
FriendRequestSent = 1,
|
||||||
const FRIEND_RELATIONSHIP_TYPE = 3
|
FriendRequestReceived = 2,
|
||||||
|
Friend = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schema DDL (mirror of migrations/0001_relationship.sql, sans seed rows). */
|
||||||
|
export const RELATIONSHIP_SCHEMA_DDL: string[] = [
|
||||||
|
`CREATE TABLE IF NOT EXISTS relationship (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
requester_id INTEGER NOT NULL,
|
||||||
|
target_id INTEGER NOT NULL,
|
||||||
|
relationship_type INTEGER NOT NULL DEFAULT 0,
|
||||||
|
requester_favorited INTEGER NOT NULL DEFAULT 0,
|
||||||
|
requester_ignored INTEGER NOT NULL DEFAULT 0,
|
||||||
|
requester_muted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
target_favorited INTEGER NOT NULL DEFAULT 0,
|
||||||
|
target_ignored INTEGER NOT NULL DEFAULT 0,
|
||||||
|
target_muted INTEGER NOT NULL DEFAULT 0
|
||||||
|
)`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_relationship ON relationship (requester_id, target_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_relationship_target ON relationship (target_id)`,
|
||||||
|
]
|
||||||
|
|
||||||
|
/** A stored relationship row (snake_case columns, one row per player pair). */
|
||||||
|
interface RelationshipRow {
|
||||||
|
requester_id: number
|
||||||
|
target_id: number
|
||||||
|
relationship_type: number
|
||||||
|
requester_favorited: number
|
||||||
|
requester_ignored: number
|
||||||
|
requester_muted: number
|
||||||
|
target_favorited: number
|
||||||
|
target_ignored: number
|
||||||
|
target_muted: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The per-player relationship projection returned to the client (RelationshipResponse). */
|
||||||
|
export interface RelationshipResponse {
|
||||||
|
Favorited: number
|
||||||
|
Ignored: number
|
||||||
|
Muted: number
|
||||||
|
PlayerID: number
|
||||||
|
RelationshipType: RelationshipType
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The account ids of a player's mutual friends. Exactly one relationship row exists per
|
* The result of a friend-graph mutation. These changes are visible to BOTH players, and
|
||||||
* unordered pair, with the player on either side, so this reads both directions and
|
* each sees a different projection of the same row (the target of a request sees
|
||||||
* returns whichever id isn't the player. Non-friend rows are excluded; the order is
|
* `FriendRequestReceived` where the sender sees `Sent`), so callers get both — `self` for
|
||||||
* unspecified.
|
* the HTTP response and the acting player's notification, `other` for the target's.
|
||||||
|
*
|
||||||
|
* `changed` is false when the mutation was a no-op: re-sending a request that's already
|
||||||
|
* outstanding, befriending someone you're already friends with, accepting something that
|
||||||
|
* isn't pending. Nothing was written, so no RelationshipChanged notification should go out
|
||||||
|
* (the reference server is likewise silent on its no-change branch).
|
||||||
|
*/
|
||||||
|
export interface RelationshipChange {
|
||||||
|
self: RelationshipResponse
|
||||||
|
other: RelationshipResponse
|
||||||
|
changed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The projection reported for a pair with no stored relationship. */
|
||||||
|
function noneResponse(otherId: number): RelationshipResponse {
|
||||||
|
return {
|
||||||
|
PlayerID: otherId,
|
||||||
|
RelationshipType: RelationshipType.None,
|
||||||
|
Favorited: 0,
|
||||||
|
Ignored: 0,
|
||||||
|
Muted: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flip a pending request to the other side's point of view; Friend/None are symmetric. */
|
||||||
|
function flipType(type: number): RelationshipType {
|
||||||
|
if (type === RelationshipType.FriendRequestSent) return RelationshipType.FriendRequestReceived
|
||||||
|
if (type === RelationshipType.FriendRequestReceived) return RelationshipType.FriendRequestSent
|
||||||
|
return type as RelationshipType
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project a stored row into the RelationshipResponse for `playerId` (who must be
|
||||||
|
* one of the pair). `PlayerID` is the *other* player; the type and the
|
||||||
|
* favorited/ignored/muted flags are taken from `playerId`'s side of the row.
|
||||||
|
*/
|
||||||
|
function toResponse(row: RelationshipRow, playerId: number): RelationshipResponse {
|
||||||
|
const isRequester = row.requester_id === playerId
|
||||||
|
return {
|
||||||
|
PlayerID: isRequester ? row.target_id : row.requester_id,
|
||||||
|
RelationshipType: isRequester ? (row.relationship_type as RelationshipType) : flipType(row.relationship_type),
|
||||||
|
Favorited: isRequester ? row.requester_favorited : row.target_favorited,
|
||||||
|
Ignored: isRequester ? row.requester_ignored : row.target_ignored,
|
||||||
|
Muted: isRequester ? row.requester_muted : row.target_muted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project a written row for both players in the pair. */
|
||||||
|
function toChange(
|
||||||
|
row: RelationshipRow,
|
||||||
|
playerId: number,
|
||||||
|
otherId: number,
|
||||||
|
changed: boolean
|
||||||
|
): RelationshipChange {
|
||||||
|
return { self: toResponse(row, playerId), other: toResponse(row, otherId), changed }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find the single row for an unordered pair (either direction), or null. */
|
||||||
|
async function findPair(db: D1Database, a: number, b: number): Promise<RelationshipRow | null> {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM relationship
|
||||||
|
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
|
||||||
|
)
|
||||||
|
.bind(a, b)
|
||||||
|
.first<RelationshipRow>()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All of a player's relationships, projected from that player's point of view.
|
||||||
|
*
|
||||||
|
* `None` rows are included: they are how an unfriending, or an ignore/mute of someone you
|
||||||
|
* were never friends with, is recorded, and they still carry that player's
|
||||||
|
* favorited/ignored/muted flags. Dropping them would lose the flags on the client.
|
||||||
|
*/
|
||||||
|
export async function getRelationshipsForPlayer(
|
||||||
|
db: D1Database,
|
||||||
|
playerId: number
|
||||||
|
): Promise<RelationshipResponse[]> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM relationship
|
||||||
|
WHERE requester_id = ?1 OR target_id = ?1`
|
||||||
|
)
|
||||||
|
.bind(playerId)
|
||||||
|
.all<RelationshipRow>()
|
||||||
|
return results.map((row) => toResponse(row, playerId))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ids of everyone a player is actually friends with — `Friend` rows only, from
|
||||||
|
* either side of the pair (the row records one direction, the friendship is mutual).
|
||||||
|
* Pending requests and `None` rows are excluded, unlike
|
||||||
|
* {@link getRelationshipsForPlayer}, which reports the whole graph.
|
||||||
*/
|
*/
|
||||||
export async function getFriendIds(db: D1Database, playerId: number): Promise<number[]> {
|
export async function getFriendIds(db: D1Database, playerId: number): Promise<number[]> {
|
||||||
const { results } = await db
|
const { results } = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT requester_id, target_id FROM relationship
|
`SELECT CASE WHEN requester_id = ?1 THEN target_id ELSE requester_id END AS id
|
||||||
|
FROM relationship
|
||||||
WHERE relationship_type = ?2 AND (requester_id = ?1 OR target_id = ?1)`
|
WHERE relationship_type = ?2 AND (requester_id = ?1 OR target_id = ?1)`
|
||||||
)
|
)
|
||||||
.bind(playerId, FRIEND_RELATIONSHIP_TYPE)
|
.bind(playerId, RelationshipType.Friend)
|
||||||
.all<{ requester_id: number; target_id: number }>()
|
.all<{ id: number }>()
|
||||||
return results.map((r) => (r.requester_id === playerId ? r.target_id : r.requester_id))
|
return results.map((r) => r.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,7 +194,260 @@ export async function areFriends(
|
|||||||
AND ((requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1))
|
AND ((requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1))
|
||||||
LIMIT 1`
|
LIMIT 1`
|
||||||
)
|
)
|
||||||
.bind(playerId, otherId, FRIEND_RELATIONSHIP_TYPE)
|
.bind(playerId, otherId, RelationshipType.Friend)
|
||||||
.first<{ ok: number }>()
|
.first<{ ok: number }>()
|
||||||
return row !== null
|
return row !== null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How many mutual friends the mutual-friends lookup will return at most. */
|
||||||
|
export const MUTUAL_FRIENDS_LIMIT = 100
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ids two players are both friends with — the intersection of their friend lists,
|
||||||
|
* ascending and capped at {@link MUTUAL_FRIENDS_LIMIT}. Each player's friends are a
|
||||||
|
* small set, so the intersection is done in memory rather than as a SQL INTERSECT.
|
||||||
|
*/
|
||||||
|
export async function getMutualFriendIds(
|
||||||
|
db: D1Database,
|
||||||
|
playerId: number,
|
||||||
|
otherId: number
|
||||||
|
): Promise<number[]> {
|
||||||
|
const [mine, theirs] = await Promise.all([
|
||||||
|
getFriendIds(db, playerId),
|
||||||
|
getFriendIds(db, otherId),
|
||||||
|
])
|
||||||
|
const ours = new Set(theirs)
|
||||||
|
return mine
|
||||||
|
.filter((id) => ours.has(id))
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.slice(0, MUTUAL_FRIENDS_LIMIT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist `type` for the pair, with `requesterId` recorded as the row's
|
||||||
|
* requester. Inserts a new row or, if one already exists for the pair (either
|
||||||
|
* direction), rewrites it so the requester is normalized to `requesterId` and
|
||||||
|
* the flags are preserved for whichever side each player is on. Returns the
|
||||||
|
* row as written, for the caller to project onto whichever side it needs.
|
||||||
|
*/
|
||||||
|
async function upsertPair(
|
||||||
|
db: D1Database,
|
||||||
|
requesterId: number,
|
||||||
|
targetId: number,
|
||||||
|
type: RelationshipType
|
||||||
|
): Promise<RelationshipRow> {
|
||||||
|
const existing = await findPair(db, requesterId, targetId)
|
||||||
|
if (!existing) {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO relationship (requester_id, target_id, relationship_type)
|
||||||
|
VALUES (?1, ?2, ?3)`
|
||||||
|
)
|
||||||
|
.bind(requesterId, targetId, type)
|
||||||
|
.run()
|
||||||
|
return {
|
||||||
|
requester_id: requesterId,
|
||||||
|
target_id: targetId,
|
||||||
|
relationship_type: type,
|
||||||
|
requester_favorited: 0,
|
||||||
|
requester_ignored: 0,
|
||||||
|
requester_muted: 0,
|
||||||
|
target_favorited: 0,
|
||||||
|
target_ignored: 0,
|
||||||
|
target_muted: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep each player's flags with that player as the row is normalized to
|
||||||
|
// requester = requesterId.
|
||||||
|
const reqIsRequester = existing.requester_id === requesterId
|
||||||
|
const reqFlags = {
|
||||||
|
favorited: reqIsRequester ? existing.requester_favorited : existing.target_favorited,
|
||||||
|
ignored: reqIsRequester ? existing.requester_ignored : existing.target_ignored,
|
||||||
|
muted: reqIsRequester ? existing.requester_muted : existing.target_muted,
|
||||||
|
}
|
||||||
|
const tgtFlags = {
|
||||||
|
favorited: reqIsRequester ? existing.target_favorited : existing.requester_favorited,
|
||||||
|
ignored: reqIsRequester ? existing.target_ignored : existing.requester_ignored,
|
||||||
|
muted: reqIsRequester ? existing.target_muted : existing.requester_muted,
|
||||||
|
}
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE relationship
|
||||||
|
SET requester_id = ?1, target_id = ?2, relationship_type = ?3,
|
||||||
|
requester_favorited = ?4, requester_ignored = ?5, requester_muted = ?6,
|
||||||
|
target_favorited = ?7, target_ignored = ?8, target_muted = ?9
|
||||||
|
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
requesterId,
|
||||||
|
targetId,
|
||||||
|
type,
|
||||||
|
reqFlags.favorited,
|
||||||
|
reqFlags.ignored,
|
||||||
|
reqFlags.muted,
|
||||||
|
tgtFlags.favorited,
|
||||||
|
tgtFlags.ignored,
|
||||||
|
tgtFlags.muted
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
return {
|
||||||
|
requester_id: requesterId,
|
||||||
|
target_id: targetId,
|
||||||
|
relationship_type: type,
|
||||||
|
requester_favorited: reqFlags.favorited,
|
||||||
|
requester_ignored: reqFlags.ignored,
|
||||||
|
requester_muted: reqFlags.muted,
|
||||||
|
target_favorited: tgtFlags.favorited,
|
||||||
|
target_ignored: tgtFlags.ignored,
|
||||||
|
target_muted: tgtFlags.muted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a friend request from `requesterId` to `targetId`. If the target already
|
||||||
|
* has a pending request out to the requester, the two become friends instead
|
||||||
|
* (the request crosses an existing one). Already-friends, and re-sending a request
|
||||||
|
* that's already outstanding, are no-ops.
|
||||||
|
*/
|
||||||
|
export async function sendFriendRequest(
|
||||||
|
db: D1Database,
|
||||||
|
requesterId: number,
|
||||||
|
targetId: number
|
||||||
|
): Promise<RelationshipChange> {
|
||||||
|
const existing = await findPair(db, requesterId, targetId)
|
||||||
|
if (existing) {
|
||||||
|
// Already friends, or we already have a request out to them — nothing to write.
|
||||||
|
if (
|
||||||
|
existing.relationship_type === RelationshipType.Friend ||
|
||||||
|
(existing.requester_id === requesterId &&
|
||||||
|
existing.relationship_type === RelationshipType.FriendRequestSent)
|
||||||
|
) {
|
||||||
|
return toChange(existing, requesterId, targetId, false)
|
||||||
|
}
|
||||||
|
// The target already requested us → crossing requests become a friendship.
|
||||||
|
if (
|
||||||
|
existing.requester_id === targetId &&
|
||||||
|
existing.relationship_type === RelationshipType.FriendRequestSent
|
||||||
|
) {
|
||||||
|
const row = await upsertPair(db, requesterId, targetId, RelationshipType.Friend)
|
||||||
|
return toChange(row, requesterId, targetId, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const row = await upsertPair(db, requesterId, targetId, RelationshipType.FriendRequestSent)
|
||||||
|
return toChange(row, requesterId, targetId, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `accepterId` accepts a pending friend request from `otherId`. Only upgrades to
|
||||||
|
* Friend when a request from `otherId` is actually pending; otherwise the current
|
||||||
|
* state is returned as a no-op. (The reference server answers 403 there instead;
|
||||||
|
* we stay lenient, but either way nothing changed.)
|
||||||
|
*/
|
||||||
|
export async function acceptFriendRequest(
|
||||||
|
db: D1Database,
|
||||||
|
accepterId: number,
|
||||||
|
otherId: number
|
||||||
|
): Promise<RelationshipChange> {
|
||||||
|
const existing = await findPair(db, accepterId, otherId)
|
||||||
|
if (
|
||||||
|
existing &&
|
||||||
|
existing.requester_id === otherId &&
|
||||||
|
existing.relationship_type === RelationshipType.FriendRequestSent
|
||||||
|
) {
|
||||||
|
const row = await upsertPair(db, otherId, accepterId, RelationshipType.Friend)
|
||||||
|
return toChange(row, accepterId, otherId, true)
|
||||||
|
}
|
||||||
|
return existing
|
||||||
|
? toChange(existing, accepterId, otherId, false)
|
||||||
|
: { self: noneResponse(otherId), other: noneResponse(accepterId), changed: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directly make `requesterId` and `targetId` friends (no pending request step).
|
||||||
|
*/
|
||||||
|
export async function addFriend(
|
||||||
|
db: D1Database,
|
||||||
|
requesterId: number,
|
||||||
|
targetId: number
|
||||||
|
): Promise<RelationshipChange> {
|
||||||
|
const existing = await findPair(db, requesterId, targetId)
|
||||||
|
if (existing && existing.relationship_type === RelationshipType.Friend) {
|
||||||
|
return toChange(existing, requesterId, targetId, false)
|
||||||
|
}
|
||||||
|
const row = await upsertPair(db, requesterId, targetId, RelationshipType.Friend)
|
||||||
|
return toChange(row, requesterId, targetId, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove any relationship between the two players (unfriend / cancel request /
|
||||||
|
* decline).
|
||||||
|
*
|
||||||
|
* The row is set to `None` rather than deleted, matching the reference server: the
|
||||||
|
* per-player favorited/ignored/muted flags live on that row and must survive an
|
||||||
|
* unfriending (someone you ignored stays ignored after you drop them as a friend).
|
||||||
|
*/
|
||||||
|
export async function removeFriend(
|
||||||
|
db: D1Database,
|
||||||
|
playerId: number,
|
||||||
|
otherId: number
|
||||||
|
): Promise<RelationshipChange> {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE relationship SET relationship_type = ?3
|
||||||
|
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
|
||||||
|
)
|
||||||
|
.bind(playerId, otherId, RelationshipType.None)
|
||||||
|
.run()
|
||||||
|
const updated = await findPair(db, playerId, otherId)
|
||||||
|
return updated
|
||||||
|
? toChange(updated, playerId, otherId, true)
|
||||||
|
: { self: noneResponse(otherId), other: noneResponse(playerId), changed: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A per-player relationship flag — each is stored on the player's own side of the row. */
|
||||||
|
export type RelationshipFlag = 'favorited' | 'ignored' | 'muted'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set one of `playerId`'s per-side flags (favorited/ignored/muted) on their
|
||||||
|
* relationship with `otherId`. These flags are stored per player, so the write
|
||||||
|
* targets the caller's OWN side of the row — `requester_*` when the caller
|
||||||
|
* initiated the pair, `target_*` otherwise. When the pair has no relationship yet
|
||||||
|
* (you can ignore/mute someone you aren't friends with) a fresh `None` row is
|
||||||
|
* created with the caller as requester. Returns the relationship from `playerId`'s
|
||||||
|
* point of view. The `flag`/side names are a fixed union, so interpolating them
|
||||||
|
* into the SQL is safe (same pattern as the room interaction toggles).
|
||||||
|
*/
|
||||||
|
export async function setRelationshipFlag(
|
||||||
|
db: D1Database,
|
||||||
|
playerId: number,
|
||||||
|
otherId: number,
|
||||||
|
flag: RelationshipFlag,
|
||||||
|
value: boolean
|
||||||
|
): Promise<RelationshipResponse> {
|
||||||
|
const existing = await findPair(db, playerId, otherId)
|
||||||
|
const v = value ? 1 : 0
|
||||||
|
if (!existing) {
|
||||||
|
// New row: the caller is the requester, so the flag lives on the requester side.
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO relationship (requester_id, target_id, relationship_type, requester_${flag})
|
||||||
|
VALUES (?1, ?2, ?3, ?4)`
|
||||||
|
)
|
||||||
|
.bind(playerId, otherId, RelationshipType.None, v)
|
||||||
|
.run()
|
||||||
|
} else {
|
||||||
|
// Update whichever side the caller is on, leaving the other player's flag alone.
|
||||||
|
const side = existing.requester_id === playerId ? 'requester' : 'target'
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE relationship SET ${side}_${flag} = ?3
|
||||||
|
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
|
||||||
|
)
|
||||||
|
.bind(playerId, otherId, v)
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await findPair(db, playerId, otherId)
|
||||||
|
return updated ? toResponse(updated, playerId) : noneResponse(otherId)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user