diff --git a/apps/api/src/images-db.ts b/apps/api/src/images-db.ts deleted file mode 100644 index 7d6359a..0000000 --- a/apps/api/src/images-db.ts +++ /dev/null @@ -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 { - // 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 { - 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 { - 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> { - 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 { - const row = await db - .prepare('SELECT data FROM image WHERE image_name = ?1') - .bind(name) - .first() - 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 { - 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 { - const { results } = await db - .prepare('SELECT data FROM image WHERE room_id = ?1') - .bind(roomId) - .all() - 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 { - const { results } = await db - .prepare('SELECT data FROM image WHERE player_id = ?1') - .bind(playerId) - .all() - 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> { - 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> { - 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 { - 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() - 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" 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 { - 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() - return results - .map((r) => JSON.parse(r.data) as SavedImage) - .filter((img) => img.Accessibility === 1) - .sort(newestFirst) - .slice(skip, skip + take) -} diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index e0e6995..5f1e784 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -505,12 +505,6 @@ export const PlayerEventsPage = z.object({ Events: JsonArray, }) -/** `POST /api/CampusCard/v1/UpdateAndGetSubscription` — both null (no subs yet). */ -export const SubscriptionResponse = z.object({ - subscription: z.null(), - platformAccountSubscribedPlayerId: z.null(), -}) - // ---- Moderation ------------------------------------------------------------ /** diff --git a/apps/api/src/relationships-db.ts b/apps/api/src/relationships-db.ts deleted file mode 100644 index 42c2f9c..0000000 --- a/apps/api/src/relationships-db.ts +++ /dev/null @@ -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 { - 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() -} - -/** - * 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 { - const { results } = await db - .prepare( - `SELECT * FROM relationship - WHERE requester_id = ?1 OR target_id = ?1` - ) - .bind(playerId) - .all() - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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) -} diff --git a/apps/api/src/routes/gameplay.ts b/apps/api/src/routes/gameplay.ts index 8a0ebee..53ea870 100644 --- a/apps/api/src/routes/gameplay.ts +++ b/apps/api/src/routes/gameplay.ts @@ -14,13 +14,12 @@ import { KeepsakeConfig, SanitizeRequest, stringParam, - SubscriptionResponse, } from '../openapi' import type { App } from '../context' -// Text sanitization, keepsakes, objectives/events/rewards, and the misc -// analytics/subscription sinks the client hits during load. +// Text sanitization, keepsakes, objectives/events/rewards, and the misc analytics +// sinks the client hits during load. export const gameplayRoutes = new Hono({ strict: false }) // Text sanitization (display names, room names, chat). `v1` echoes the input // value back; `isPure` reports the text is clean. @@ -150,17 +149,3 @@ export const gameplayRoutes = new Hono({ strict: false }) }), (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 }) - ) diff --git a/apps/api/src/routes/images.ts b/apps/api/src/routes/images.ts index 34b3f96..e364f45 100644 --- a/apps/api/src/routes/images.ts +++ b/apps/api/src/routes/images.ts @@ -1,7 +1,6 @@ import { Hono } from 'hono' import { describeRoute } from 'hono-openapi' -import { authedId, unauthorized } from '../http' import { createImage, deleteImage, @@ -16,7 +15,9 @@ import { SLIDESHOW_LIMIT, SLIDESHOW_MAX_LIMIT, toImagesPlayer, -} from '../images-db' +} from '@repo/domain' + +import { authedId, unauthorized } from '../http' import { AUTHED, CheeredEntry, diff --git a/apps/api/src/routes/social.ts b/apps/api/src/routes/social.ts index 9bd3321..2ce2217 100644 --- a/apps/api/src/routes/social.ts +++ b/apps/api/src/routes/social.ts @@ -1,7 +1,17 @@ import { Hono } from 'hono' 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' // The notification-type ids the hub carries (owned by the `notify` worker). Imported @@ -24,24 +34,14 @@ import { SuccessErrorEnvelope, UNAUTHORIZED_RESPONSE, } from '../openapi' -import { - acceptFriendRequest, - addFriend, - getMutualFriendIds, - getRelationshipsForPlayer, - MUTUAL_FRIENDS_LIMIT, - removeFriend, - sendFriendRequest, - setRelationshipFlag, -} from '../relationships-db' import type { Context } from 'hono' -import type { App } from '../context' import type { RelationshipChange, RelationshipFlag, 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). */ const HUB_INSTANCE = 'global' diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 28aa059..f391f15 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -5,13 +5,17 @@ import { beforeAll, describe, expect, test } from 'vitest' import { addXp, applyLevelUps, + createImage, GAME_VERSION, + getImageByName, grantInvention, + IMAGE_SCHEMA_DDL, INVENTORY_INVENTION_SCHEMA_DDL, LEVEL_REQUIRED_XP, LEVEL_REWARDS, MAX_LEVEL, PROGRESSION_SCHEMA_DDL, + RELATIONSHIP_SCHEMA_DDL, ROOM_SCHEMA_DDL, seedRoomWithSubRooms, SUBROOM_SCHEMA_DDL, @@ -27,9 +31,7 @@ import { getEventAttendees, getEventResponse, } 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 RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-db' import { banFromReport, createReport, @@ -40,9 +42,9 @@ import { } from '../../reports-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 { PlayerEvent, PlayerEventResult } from '../../events-db' -import type { SavedImage } from '../../images-db' import type { InventionSaveResult, SavedInvention } from '../../inventions-db' declare module 'cloudflare:test' { @@ -100,10 +102,10 @@ beforeAll(async () => { .run() // 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. - 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. 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/versioncheck/v4', 'GET /voice/config', - 'POST /api/CampusCard/v1/UpdateAndGetSubscription', 'POST /api/PlayerReporting/v1/deviceId', 'POST /api/PlayerReporting/v1/hile', 'POST /api/PlayerReporting/v3/create', diff --git a/apps/clubs/src/clubs-db.ts b/apps/clubs/src/clubs-db.ts deleted file mode 100644 index c0c4d0f..0000000 --- a/apps/clubs/src/clubs-db.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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> = {} -): 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 { - 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 { - 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 { - 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 { - 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() - - 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 { - 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 { - 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 { - 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 { - 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 { - const row = await db - .prepare('SELECT data FROM club WHERE club_id = ?1') - .bind(clubId) - .first() - 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() - 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 { - const row = await db - .prepare('SELECT data FROM club WHERE club_id = ?1') - .bind(clubId) - .first() - 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 { - 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 { - const row = await db - .prepare('SELECT data FROM club WHERE club_id = ?1') - .bind(clubId) - .first() - 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 { - const row = await db - .prepare('SELECT data FROM club WHERE club_id = ?1') - .bind(clubId) - .first() - 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 { - return parseOne( - await db.prepare('SELECT data FROM club WHERE club_id = ?1').bind(clubId).first() - ) -} - -/** - * 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 { - 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 { - 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 { - 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() - 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 { - 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() - 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 { - 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 { - 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 { - 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 } -} diff --git a/apps/clubs/src/clubs.app.ts b/apps/clubs/src/clubs.app.ts index ab87307..e1ebf1e 100644 --- a/apps/clubs/src/clubs.app.ts +++ b/apps/clubs/src/clubs.app.ts @@ -2,14 +2,6 @@ import { Hono } from 'hono' import { describeRoute, openAPIRouteHandler } from 'hono-openapi' 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 { clearHomeClub, ClubJoinability, @@ -27,16 +19,22 @@ import { getClubsByMember, getHomeClub, getMembership, + glyphLength, joinClub, leaveClub, MAX_ADDITIONAL_IMAGES, + MAX_CLUB_DESCRIPTION_LENGTH, + MAX_CLUB_NAME_LENGTH, requestToJoinClub, searchClubs, setClubAdditionalImage, setHomeClub, setMemberType, updateClub, -} from './clubs-db' +} from '@repo/domain' +import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' +import { validateAndGetAccountId } from '@repo/jwt' + import { AnnouncementIdEnvelope, AnnouncementRequest, diff --git a/apps/clubs/src/test/integration/api.test.ts b/apps/clubs/src/test/integration/api.test.ts index 3776726..d9bb58a 100644 --- a/apps/clubs/src/test/integration/api.test.ts +++ b/apps/clubs/src/test/integration/api.test.ts @@ -4,7 +4,7 @@ import { beforeAll, describe, expect, test } from 'vitest' import '../../clubs.app' -import { SCHEMA_DDL } from '../../clubs-db' +import { CLUB_SCHEMA_DDL } from '@repo/domain' 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. await adminSecretsStore(env.JWT_SECRET).create('test-signing-key') // 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 // their account row, so /club/home/me reads and writes it here. diff --git a/apps/img/src/images-db.ts b/apps/img/src/images-db.ts deleted file mode 100644 index 8a62e45..0000000 --- a/apps/img/src/images-db.ts +++ /dev/null @@ -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 { - // 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 { - const row = await db - .prepare('SELECT data FROM image WHERE image_name = ?1') - .bind(name) - .first() - return row ? (JSON.parse(row.data) as SavedImage) : null -} diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index f453a9c..eae31ea 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -641,9 +641,11 @@ const app = new Hono() // "Hot" rooms feed — public, non-dorm rooms ordered by live player count (their // 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 - // player-made (non-RRO) rooms newest-first. Paginated via skip/take (take defaults - // to 100). Returns `{ Results, TotalResults }` like search. + // `tag` (e.g. `rro`). `tag=new` and `tag=community` are pseudo-tags no room + // carries: `new` serves the player-made (non-RRO) rooms newest-first, `community` + // 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( '/rooms/hot', describeRoute({ @@ -653,11 +655,16 @@ const app = new Hono() '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', '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', - 'a `new` tag — and instead serves the player-made (non-RRO) rooms, newest first.', + 'filter chips post one, e.g. `rro`). The `new` and `community` chips are pseudo-tags —', + '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(' '), 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), ], responses: { 200: json(PagedRooms, 'The feed page') }, diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 34fc38d..db62004 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -577,6 +577,56 @@ describe('rooms endpoints', () => { 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 => + (await ( + await SELF.fetch(`${ORIGIN}/rooms/hot?tag=community&skip=0&take=100`) + ).json()) as Feed + const names = async (): Promise => (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) => { + 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 () => { const res = await SELF.fetch(`${ORIGIN}/rooms/base`) expect(res.status).toBe(200) diff --git a/packages/domain/src/clubs-db.ts b/packages/domain/src/clubs-db.ts index 9a0bd24..d1e62d7 100644 --- a/packages/domain/src/clubs-db.ts +++ b/packages/domain/src/clubs-db.ts @@ -1,25 +1,755 @@ /** - * Cross-worker *reads* of the club tables. The `clubs` worker owns the schema and - * every write (see apps/clubs/src/clubs-db.ts); this is the narrow view other - * workers need — right now `match`, which has to know a club's clubhouse room and - * whether the player asking for it is actually a member. + * 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. * - * Deliberately read-only, and deliberately small: clubs are a JSON blob in - * `club.data`, so anything that needs the whole DTO should go through the clubs - * worker's API rather than growing this file into a second copy of its 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) 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 - * `ClubMembershipType`). Only the values other workers reason about are named here; - * the tiers between are just higher numbers. + * 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 const CLUB_MEMBERSHIP_BANNED = -1 -export const CLUB_MEMBERSHIP_NONE = 0 -/** At/above this, a row is an actual member rather than pending/banned. */ -export const CLUB_MEMBERSHIP_MEMBER = 10 +export enum ClubMembershipType { + Banned = -1, + None = 0, + 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 { + 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 { + 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 { + 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 { + 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> = {} +): 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 { + 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 { + 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 { + 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 { + 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() + + 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 { + 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 { + 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 { + 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 { + 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 { + const row = await db + .prepare('SELECT data FROM club WHERE club_id = ?1') + .bind(clubId) + .first() + 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() + 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 { + const row = await db + .prepare('SELECT data FROM club WHERE club_id = ?1') + .bind(clubId) + .first() + 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 { + 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 { + const row = await db + .prepare('SELECT data FROM club WHERE club_id = ?1') + .bind(clubId) + .first() + 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 { + const row = await db + .prepare('SELECT data FROM club WHERE club_id = ?1') + .bind(clubId) + .first() + 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 { + return parseOne( + await db.prepare('SELECT data FROM club WHERE club_id = ?1').bind(clubId).first() + ) +} + +/** The two club fields other workers read without wanting the whole DTO. */ export interface ClubSummary { clubId: number name: string @@ -27,7 +757,11 @@ export interface ClubSummary { 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 { const row = await db .prepare( @@ -41,17 +775,87 @@ export async function getClubSummary(db: D1Database, clubId: number): Promise { +/** + * 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 { + 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 { 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 ?? CLUB_MEMBERSHIP_NONE + .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 { + 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() + 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 { + 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() + return parseAll(results) } /** Whether an account is an actual member of a club (Member tier or above). */ @@ -60,5 +864,130 @@ export async function isClubMember( clubId: number, accountId: number ): Promise { - 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 { + 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 { + 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 } } diff --git a/packages/domain/src/images-db.ts b/packages/domain/src/images-db.ts index 4adbc83..1515e13 100644 --- a/packages/domain/src/images-db.ts +++ b/packages/domain/src/images-db.ts @@ -1,16 +1,64 @@ /** - * Cross-worker *reads* of the image-metadata table. The `img` worker owns the schema - * and the `api` worker handles uploads and writes (see apps/api/src/images-db.ts); - * this is the read-only view other workers need when they store an image *name* but - * have to serve the client the whole image record — the client deserializes those - * into its `SavedImage` type, not into strings. + * 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. * - * 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). */ export interface SavedImage { Id: number + /** A {@link SavedImageType} value. */ Type: number Accessibility: number AccessibilityLocked: boolean @@ -25,10 +73,126 @@ export interface SavedImage { CommentCount: number } +interface ImageRow { + data: string +} + /** Build the `?1,?2,…` placeholder list for an `IN (…)` clause. */ const placeholders = (n: number): string => 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 { + // 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 { + 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 { + 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> { + 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 { + const row = await db + .prepare('SELECT data FROM image WHERE image_name = ?1') + .bind(name) + .first() + return row ? (JSON.parse(row.data) as SavedImage) : null +} + /** * 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. @@ -41,7 +205,7 @@ export async function getSavedImagesByNames( const { results } = await db .prepare(`SELECT data FROM image WHERE image_name IN (${placeholders(names.length)})`) .bind(...names) - .all<{ data: string }>() + .all() return new Map( results.map((r) => { const image = JSON.parse(r.data) as SavedImage @@ -72,3 +236,234 @@ export function placeholderSavedImage(imageName: string): SavedImage { 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 { + 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 { + const { results } = await db + .prepare('SELECT data FROM image WHERE room_id = ?1') + .bind(roomId) + .all() + 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 { + const { results } = await db + .prepare('SELECT data FROM image WHERE player_id = ?1') + .bind(playerId) + .all() + 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> { + 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> { + 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 { + 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() + 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" 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 { + 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() + return results + .map((r) => JSON.parse(r.data) as SavedImage) + .filter((img) => img.Accessibility === 1) + .sort(newestFirst) + .slice(skip, skip + take) +} diff --git a/packages/domain/src/relationships-db.ts b/packages/domain/src/relationships-db.ts index c727b35..bbf13bc 100644 --- a/packages/domain/src/relationships-db.ts +++ b/packages/domain/src/relationships-db.ts @@ -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 - * (apps/api/src/relationships-db.ts, migrations/0001_relationship.sql). This module is - * the shared *reader* other workers need: the `match` worker looks up a player's friends - * to push a presence update to them when the player changes rooms. It only SELECTs the - * three columns that identify a friendship (requester/target/type), so it stays - * decoupled from the favorited/ignored/muted flag columns api layers on top. + * 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 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_type` for a mutual friendship — mirror of api's `RelationshipType.Friend`. - * Pending requests (1 sent / 2 received) and bare ignore/mute rows (0) are not friends. - */ -const FRIEND_RELATIONSHIP_TYPE = 3 +/** 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 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 - * unordered pair, with the player on either side, so this reads both directions and - * returns whichever id isn't the player. Non-friend rows are excluded; the order is - * unspecified. + * 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 { + 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() +} + +/** + * 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 { + const { results } = await db + .prepare( + `SELECT * FROM relationship + WHERE requester_id = ?1 OR target_id = ?1` + ) + .bind(playerId) + .all() + 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 { const { results } = await db .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)` ) - .bind(playerId, FRIEND_RELATIONSHIP_TYPE) - .all<{ requester_id: number; target_id: number }>() - return results.map((r) => (r.requester_id === playerId ? r.target_id : r.requester_id)) + .bind(playerId, RelationshipType.Friend) + .all<{ id: number }>() + 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)) LIMIT 1` ) - .bind(playerId, otherId, FRIEND_RELATIONSHIP_TYPE) + .bind(playerId, otherId, RelationshipType.Friend) .first<{ ok: number }>() 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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) +}