mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[techdebt] cleanup duplicate schema definitions
This commit is contained in:
@@ -1,421 +0,0 @@
|
||||
/**
|
||||
* Image-metadata storage on the shared `recflare` D1 database. Each image is a
|
||||
* single JSON blob in the `data` column; queryable fields (Id, ImageName,
|
||||
* PlayerId, RoomId) are SQLite generated (virtual) columns extracted from that
|
||||
* JSON — the same JSON-blob pattern the rooms/accounts tables use.
|
||||
*
|
||||
* Mirror of `apps/img/src/images-db.ts` — the `img` worker owns the schema and
|
||||
* migration; this worker (which handles uploads + reads) keeps a copy in sync.
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of migrations/0001_image.sql, sans any seed rows). */
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS image (
|
||||
data TEXT NOT NULL,
|
||||
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Id')) VIRTUAL,
|
||||
image_name TEXT GENERATED ALWAYS AS (json_extract(data, '$.ImageName')) VIRTUAL,
|
||||
player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.PlayerId')) VIRTUAL,
|
||||
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_image_id ON image (id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_image_image_name ON image (image_name)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_image_player_id ON image (player_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_image_room_id ON image (room_id)`,
|
||||
// A player's interaction with a saved image — one row per (player, image). Only
|
||||
// `cheered` for now; named generically so other per-user interactions (e.g.
|
||||
// favorited) can be added as columns. This worker writes it (cheer endpoints) and
|
||||
// keeps the image's denormalized `CheerCount` in sync from it. Schema owned by the
|
||||
// `img` worker (migrations/0002_image_interaction.sql) — keep in sync.
|
||||
`CREATE TABLE IF NOT EXISTS image_interaction (
|
||||
player_id INTEGER NOT NULL,
|
||||
saved_image_id INTEGER NOT NULL,
|
||||
cheered INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT,
|
||||
PRIMARY KEY (player_id, saved_image_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_image_interaction_image ON image_interaction (saved_image_id)`,
|
||||
]
|
||||
|
||||
/**
|
||||
* Saved-image categories from the reference's `SavedImageType` enum — the value of a
|
||||
* stored image's `Type` (and the client's `imgMeta.savedImageType` on upload). Lives
|
||||
* here in the image data layer so both the upload route and the slideshow query share
|
||||
* one definition.
|
||||
*/
|
||||
export const SavedImageType = {
|
||||
None: 0,
|
||||
ShareCamera: 1,
|
||||
OutfitThumbnail: 2,
|
||||
RoomThumbnail: 3,
|
||||
ProfileThumbnail: 4,
|
||||
InventionThumbnail: 5,
|
||||
} as const
|
||||
|
||||
/** A stored image record (the client-facing SavedImage shape). */
|
||||
export interface SavedImage {
|
||||
Id: number
|
||||
/** A {@link SavedImageType} value. */
|
||||
Type: number
|
||||
Accessibility: number
|
||||
AccessibilityLocked: boolean
|
||||
ImageName: string
|
||||
Description: string | null
|
||||
PlayerId: number
|
||||
TaggedPlayerIds: number[]
|
||||
RoomId: number | null
|
||||
PlayerEventId: number | null
|
||||
CreatedAt: string
|
||||
CheerCount: number
|
||||
CommentCount: number
|
||||
}
|
||||
|
||||
interface ImageRow {
|
||||
data: string
|
||||
}
|
||||
|
||||
/** Fields supplied at upload time (from `imgMeta`); everything else defaults. */
|
||||
export interface NewImage {
|
||||
imageName: string
|
||||
playerId: number
|
||||
type?: number
|
||||
accessibility?: number
|
||||
roomId?: number | null
|
||||
description?: string | null
|
||||
taggedPlayerIds?: number[]
|
||||
playerEventId?: number | null
|
||||
}
|
||||
|
||||
/** Insert a new image record for an upload, returning the stored row. */
|
||||
export async function createImage(db: D1Database, input: NewImage): Promise<SavedImage> {
|
||||
// Sequential id: one past the current max (the table starts empty).
|
||||
const row = await db
|
||||
.prepare('SELECT COALESCE(MAX(id), 0) + 1 AS next FROM image')
|
||||
.first<{ next: number }>()
|
||||
const image: SavedImage = {
|
||||
Id: row?.next ?? 1,
|
||||
Type: input.type ?? 1,
|
||||
Accessibility: input.accessibility ?? 1,
|
||||
AccessibilityLocked: false,
|
||||
ImageName: input.imageName,
|
||||
Description: input.description ?? null,
|
||||
PlayerId: input.playerId,
|
||||
TaggedPlayerIds: input.taggedPlayerIds ?? [],
|
||||
RoomId: input.roomId ?? null,
|
||||
PlayerEventId: input.playerEventId ?? null,
|
||||
CreatedAt: new Date().toISOString(),
|
||||
CheerCount: 0,
|
||||
CommentCount: 0,
|
||||
}
|
||||
await db.prepare('INSERT INTO image (data) VALUES (?1)').bind(JSON.stringify(image)).run()
|
||||
return image
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute an image's `CheerCount` from the `image_interaction` rows and write it
|
||||
* back into the blob (nothing reads a generated column for it, but the client-facing
|
||||
* blob must stay accurate). CAST to INTEGER: D1 binds a JS number as a SQLite REAL,
|
||||
* which json_set would otherwise store as `"CheerCount":3.0`. Returns the fresh count.
|
||||
*/
|
||||
async function syncImageCheerCount(db: D1Database, savedImageId: number): Promise<number> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
'SELECT COUNT(*) AS n FROM image_interaction WHERE saved_image_id = ?1 AND cheered = 1'
|
||||
)
|
||||
.bind(savedImageId)
|
||||
.first<{ n: number }>()
|
||||
const count = row?.n ?? 0
|
||||
await db
|
||||
.prepare(
|
||||
"UPDATE image SET data = json_set(data, '$.CheerCount', CAST(?2 AS INTEGER)) WHERE id = ?1"
|
||||
)
|
||||
.bind(savedImageId, count)
|
||||
.run()
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Set (or clear) a player's cheer on a saved image — upserts the one row per
|
||||
* (player, image) — then resyncs the image's `CheerCount`. Idempotent: re-cheering
|
||||
* an already-cheered image is a no-op on the count.
|
||||
*/
|
||||
export async function setImageCheer(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
savedImageId: number,
|
||||
cheer: boolean
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO image_interaction (player_id, saved_image_id, cheered, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(player_id, saved_image_id) DO UPDATE SET cheered = ?3`
|
||||
)
|
||||
.bind(playerId, savedImageId, cheer ? 1 : 0, new Date().toISOString())
|
||||
.run()
|
||||
await syncImageCheerCount(db, savedImageId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of the given saved-image ids the player has cheered — the set of cheered
|
||||
* ids (a subset of `ids`). Backs the bulk `cheered` lookup. Empty input → empty set.
|
||||
*/
|
||||
export async function getCheeredImageIds(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
ids: number[]
|
||||
): Promise<Set<number>> {
|
||||
if (ids.length === 0) return new Set()
|
||||
const inList = ids.map((_, i) => `?${i + 2}`).join(',')
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT saved_image_id AS id FROM image_interaction
|
||||
WHERE player_id = ?1 AND cheered = 1 AND saved_image_id IN (${inList})`
|
||||
)
|
||||
.bind(playerId, ...ids)
|
||||
.all<{ id: number }>()
|
||||
return new Set(results.map((r) => r.id))
|
||||
}
|
||||
|
||||
/** Look up an image record by its ImageName (the R2 key / filename), or null. */
|
||||
export async function getImageByName(db: D1Database, name: string): Promise<SavedImage | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT data FROM image WHERE image_name = ?1')
|
||||
.bind(name)
|
||||
.first<ImageRow>()
|
||||
return row ? (JSON.parse(row.data) as SavedImage) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an image's metadata row plus any per-player interactions (cheers) recorded
|
||||
* against it, in one batch — the row keyed by ImageName (the R2 key), its interactions
|
||||
* by the image's `Id`. Authorization and removing the object from R2 are the caller's
|
||||
* responsibility (see the deletesaved route).
|
||||
*/
|
||||
export async function deleteImage(db: D1Database, image: SavedImage): Promise<void> {
|
||||
await db.batch([
|
||||
db.prepare('DELETE FROM image WHERE image_name = ?1').bind(image.ImageName),
|
||||
db.prepare('DELETE FROM image_interaction WHERE saved_image_id = ?1').bind(image.Id),
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* The public images taken in a room, for the room's photo feed. Only publicly
|
||||
* accessible images (Accessibility === 1) are returned. `filter` narrows by
|
||||
* `SavedImageType` (0 = all types); `sort` orders the feed — `1` puts the most
|
||||
* cheered first (ties broken by newest), anything else is newest-first. Paginated
|
||||
* via skip/take; returns a bare array of SavedImage. The per-room set is small, so
|
||||
* the room_id index does the lookup and filtering/sorting happens in memory.
|
||||
*
|
||||
* NOTE: the exact `sort`/`filter` enum values are best guesses — the client sends
|
||||
* `sort=1&filter=1`, and this treats them as most-cheered / ShareCamera.
|
||||
*/
|
||||
export async function getImagesByRoom(
|
||||
db: D1Database,
|
||||
roomId: number,
|
||||
sort: number,
|
||||
filter: number,
|
||||
skip: number,
|
||||
take: number
|
||||
): Promise<SavedImage[]> {
|
||||
const { results } = await db
|
||||
.prepare('SELECT data FROM image WHERE room_id = ?1')
|
||||
.bind(roomId)
|
||||
.all<ImageRow>()
|
||||
let images = results
|
||||
.map((r) => JSON.parse(r.data) as SavedImage)
|
||||
.filter((img) => img.Accessibility === 1)
|
||||
|
||||
if (filter > 0) images = images.filter((img) => img.Type === filter)
|
||||
|
||||
images.sort(sort === 1 ? (a, b) => b.CheerCount - a.CheerCount || newestFirst(a, b) : newestFirst)
|
||||
|
||||
return images.slice(skip, skip + take)
|
||||
}
|
||||
|
||||
/** Newest-first order: most recent CreatedAt, ties broken by higher Id. */
|
||||
const newestFirst = (a: SavedImage, b: SavedImage) =>
|
||||
b.CreatedAt.localeCompare(a.CreatedAt) || b.Id - a.Id
|
||||
|
||||
/**
|
||||
* The public images a player has taken — their photo list, newest first.
|
||||
* Paginated via skip/take; returns a bare array of SavedImage. Uses the
|
||||
* player_id index; the per-player set is small, so filtering/sorting is in memory.
|
||||
*/
|
||||
export async function getImagesByPlayer(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
sort: number,
|
||||
skip: number,
|
||||
take: number
|
||||
): Promise<SavedImage[]> {
|
||||
const { results } = await db
|
||||
.prepare('SELECT data FROM image WHERE player_id = ?1')
|
||||
.bind(playerId)
|
||||
.all<ImageRow>()
|
||||
return results
|
||||
.map((r) => JSON.parse(r.data) as SavedImage)
|
||||
.filter((img) => img.Accessibility === 1)
|
||||
.sort(sort === 1 ? (a, b) => b.CheerCount - a.CheerCount || newestFirst(a, b) : newestFirst)
|
||||
.slice(skip, skip + take)
|
||||
}
|
||||
|
||||
/**
|
||||
* The client-facing projection of a saved image for the player photo lists (the
|
||||
* reference's `ImagesPlayer`). Same data as the stored record, but the id and type
|
||||
* are renamed — `Id` → `SavedImageId`, `Type` → `SavedImageType` — and the tagged
|
||||
* player ids aren't part of it. The client deserializes into this shape, so a raw
|
||||
* SavedImage leaves it without an image id and its thumbnails come up blank.
|
||||
*/
|
||||
export interface ImagesPlayer {
|
||||
Accessibility: number
|
||||
AccessibilityLocked: boolean
|
||||
CheerCount: number
|
||||
CommentCount: number
|
||||
CreatedAt: string
|
||||
Description: string | null
|
||||
ImageName: string
|
||||
PlayerEventId: number | null
|
||||
PlayerId: number
|
||||
RoomId: number | null
|
||||
SavedImageId: number
|
||||
SavedImageType: number
|
||||
}
|
||||
|
||||
/** Project a stored image to the client's ImagesPlayer shape. */
|
||||
export function toImagesPlayer(img: SavedImage): ImagesPlayer {
|
||||
return {
|
||||
Accessibility: img.Accessibility,
|
||||
AccessibilityLocked: img.AccessibilityLocked,
|
||||
CheerCount: img.CheerCount,
|
||||
CommentCount: img.CommentCount,
|
||||
CreatedAt: img.CreatedAt,
|
||||
Description: img.Description,
|
||||
ImageName: img.ImageName,
|
||||
PlayerEventId: img.PlayerEventId,
|
||||
PlayerId: img.PlayerId,
|
||||
RoomId: img.RoomId,
|
||||
SavedImageId: img.Id,
|
||||
SavedImageType: img.Type,
|
||||
}
|
||||
}
|
||||
|
||||
/** How many recent images the slideshow feed returns when the caller doesn't say. */
|
||||
export const SLIDESHOW_LIMIT = 10
|
||||
|
||||
/**
|
||||
* The most a caller can ask the slideshow feed for. The endpoint is public and
|
||||
* unauthenticated, so the cap is what keeps an arbitrary `take` from turning into a
|
||||
* scan of the whole image table plus the two batched joins behind it.
|
||||
*/
|
||||
export const SLIDESHOW_MAX_LIMIT = 100
|
||||
|
||||
/** The slideshow projection of an image — creator username + room name joined in. */
|
||||
export interface SlideshowImage {
|
||||
SavedImageId: number
|
||||
ImageName: string
|
||||
Username: string
|
||||
RoomName: string | null
|
||||
RoomId: number | null
|
||||
SavedImageType: number
|
||||
PlayerEventId: number | null
|
||||
Accessibility: number
|
||||
PlayerIds: number[]
|
||||
}
|
||||
|
||||
/** Build the `?1,?2,…` placeholder list for an `IN (…)` clause. */
|
||||
const placeholders = (n: number): string =>
|
||||
Array.from({ length: n }, (_, i) => `?${i + 1}`).join(',')
|
||||
|
||||
/** Map account ids → username, resolved from the shared accounts table. */
|
||||
async function getUsernames(db: D1Database, ids: number[]): Promise<Map<number, string>> {
|
||||
if (ids.length === 0) return new Map()
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT account_id AS id, json_extract(data, '$.username') AS username
|
||||
FROM account WHERE account_id IN (${placeholders(ids.length)})`
|
||||
)
|
||||
.bind(...ids)
|
||||
.all<{ id: number; username: string }>()
|
||||
return new Map(results.map((r) => [r.id, r.username]))
|
||||
}
|
||||
|
||||
/** Map room ids → room name, resolved from the shared rooms table. */
|
||||
async function getRoomNames(db: D1Database, ids: number[]): Promise<Map<number, string>> {
|
||||
if (ids.length === 0) return new Map()
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT room_id AS id, json_extract(data, '$.Name') AS name
|
||||
FROM room WHERE room_id IN (${placeholders(ids.length)})`
|
||||
)
|
||||
.bind(...ids)
|
||||
.all<{ id: number; name: string }>()
|
||||
return new Map(results.map((r) => [r.id, r.name]))
|
||||
}
|
||||
|
||||
/**
|
||||
* The global slideshow feed — the most recent publicly-listable ShareCamera photos
|
||||
* across all rooms (Accessibility 0 or 1, Type 1), newest first, capped at `limit`.
|
||||
* Only ShareCamera images are surfaced (not room/profile/invention thumbnails). Each
|
||||
* row is joined to its creator's username and (if any) its room's name. Returns the
|
||||
* projected SlideshowImage shape. Usernames/room names are resolved in two batched
|
||||
* lookups to avoid an N+1 across the (at most `limit`) images.
|
||||
*/
|
||||
export async function getSlideshowImages(
|
||||
db: D1Database,
|
||||
limit = SLIDESHOW_LIMIT
|
||||
): Promise<SlideshowImage[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT data FROM image
|
||||
WHERE json_extract(data, '$.Accessibility') IN (0, 1)
|
||||
AND json_extract(data, '$.Type') = ?1
|
||||
ORDER BY id DESC LIMIT ?2`
|
||||
)
|
||||
.bind(SavedImageType.ShareCamera, limit)
|
||||
.all<ImageRow>()
|
||||
const images = results.map((r) => JSON.parse(r.data) as SavedImage)
|
||||
|
||||
const roomIds = [...new Set(images.map((i) => i.RoomId).filter((v): v is number => v != null))]
|
||||
const usernames = await getUsernames(db, [...new Set(images.map((i) => i.PlayerId))])
|
||||
const roomNames = await getRoomNames(db, roomIds)
|
||||
|
||||
return images.map((img) => ({
|
||||
SavedImageId: img.Id,
|
||||
ImageName: img.ImageName,
|
||||
// Fall back to the synthesized "Player<id>" name for accounts not in the table.
|
||||
Username: usernames.get(img.PlayerId) ?? `Player${img.PlayerId}`,
|
||||
RoomName: img.RoomId != null ? (roomNames.get(img.RoomId) ?? null) : null,
|
||||
RoomId: img.RoomId,
|
||||
SavedImageType: img.Type,
|
||||
PlayerEventId: img.PlayerEventId,
|
||||
Accessibility: img.Accessibility,
|
||||
PlayerIds: img.TaggedPlayerIds,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* A player's photo feed — the public images they took plus the ones they're
|
||||
* tagged in (TaggedPlayerIds). Newest first, paginated via skip/take; returns a
|
||||
* bare array of SavedImage. The tagged-in match uses json_each over the stored
|
||||
* TaggedPlayerIds array (there's no index for it).
|
||||
*/
|
||||
export async function getPlayerFeed(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
skip: number,
|
||||
take: number
|
||||
): Promise<SavedImage[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT data FROM image
|
||||
WHERE player_id = ?1
|
||||
OR EXISTS (SELECT 1 FROM json_each(image.data, '$.TaggedPlayerIds') WHERE value = ?1)`
|
||||
)
|
||||
.bind(playerId)
|
||||
.all<ImageRow>()
|
||||
return results
|
||||
.map((r) => JSON.parse(r.data) as SavedImage)
|
||||
.filter((img) => img.Accessibility === 1)
|
||||
.sort(newestFirst)
|
||||
.slice(skip, skip + take)
|
||||
}
|
||||
@@ -505,12 +505,6 @@ export const PlayerEventsPage = z.object({
|
||||
Events: JsonArray,
|
||||
})
|
||||
|
||||
/** `POST /api/CampusCard/v1/UpdateAndGetSubscription` — both null (no subs yet). */
|
||||
export const SubscriptionResponse = z.object({
|
||||
subscription: z.null(),
|
||||
platformAccountSubscribedPlayerId: z.null(),
|
||||
})
|
||||
|
||||
// ---- Moderation ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,427 +0,0 @@
|
||||
/**
|
||||
* Friendship / relationship storage on the shared `recflare` D1 database.
|
||||
*
|
||||
* Unlike the JSON-blob tables in this database (rooms/accounts/image), a
|
||||
* relationship is genuinely columnar, so it gets a normal relational table
|
||||
* (mirroring the Go/GORM `Relationship` model). Exactly ONE row exists per
|
||||
* unordered pair of players: the player who initiated is the `requester`, the
|
||||
* other is the `target`. `relationship_type` is stored from the requester's
|
||||
* point of view; when we project the row for the *target* we flip
|
||||
* Sent↔Received (Friend/None are symmetric).
|
||||
*
|
||||
* The `api` worker owns this schema/migration (migrations/0001_relationship.sql,
|
||||
* applied under its own `migrations_table` so it doesn't clash with the other
|
||||
* workers' migrations that share the database).
|
||||
*/
|
||||
|
||||
/** Relationship state from the perspective of the player asking (mirrors the reference). */
|
||||
export enum RelationshipType {
|
||||
None = 0,
|
||||
FriendRequestSent = 1,
|
||||
FriendRequestReceived = 2,
|
||||
Friend = 3,
|
||||
}
|
||||
|
||||
/** Schema DDL (mirror of migrations/0001_relationship.sql, sans seed rows). */
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS relationship (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
requester_id INTEGER NOT NULL,
|
||||
target_id INTEGER NOT NULL,
|
||||
relationship_type INTEGER NOT NULL DEFAULT 0,
|
||||
requester_favorited INTEGER NOT NULL DEFAULT 0,
|
||||
requester_ignored INTEGER NOT NULL DEFAULT 0,
|
||||
requester_muted INTEGER NOT NULL DEFAULT 0,
|
||||
target_favorited INTEGER NOT NULL DEFAULT 0,
|
||||
target_ignored INTEGER NOT NULL DEFAULT 0,
|
||||
target_muted INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_relationship ON relationship (requester_id, target_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_relationship_target ON relationship (target_id)`,
|
||||
]
|
||||
|
||||
/** A stored relationship row (snake_case columns, one row per player pair). */
|
||||
interface RelationshipRow {
|
||||
requester_id: number
|
||||
target_id: number
|
||||
relationship_type: number
|
||||
requester_favorited: number
|
||||
requester_ignored: number
|
||||
requester_muted: number
|
||||
target_favorited: number
|
||||
target_ignored: number
|
||||
target_muted: number
|
||||
}
|
||||
|
||||
/** The per-player relationship projection returned to the client (RelationshipResponse). */
|
||||
export interface RelationshipResponse {
|
||||
Favorited: number
|
||||
Ignored: number
|
||||
Muted: number
|
||||
PlayerID: number
|
||||
RelationshipType: RelationshipType
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of a friend-graph mutation. These changes are visible to BOTH players, and
|
||||
* each sees a different projection of the same row (the target of a request sees
|
||||
* `FriendRequestReceived` where the sender sees `Sent`), so callers get both — `self` for
|
||||
* the HTTP response and the acting player's notification, `other` for the target's.
|
||||
*
|
||||
* `changed` is false when the mutation was a no-op: re-sending a request that's already
|
||||
* outstanding, befriending someone you're already friends with, accepting something that
|
||||
* isn't pending. Nothing was written, so no RelationshipChanged notification should go out
|
||||
* (the reference server is likewise silent on its no-change branch).
|
||||
*/
|
||||
export interface RelationshipChange {
|
||||
self: RelationshipResponse
|
||||
other: RelationshipResponse
|
||||
changed: boolean
|
||||
}
|
||||
|
||||
/** The projection reported for a pair with no stored relationship. */
|
||||
function noneResponse(otherId: number): RelationshipResponse {
|
||||
return {
|
||||
PlayerID: otherId,
|
||||
RelationshipType: RelationshipType.None,
|
||||
Favorited: 0,
|
||||
Ignored: 0,
|
||||
Muted: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** Flip a pending request to the other side's point of view; Friend/None are symmetric. */
|
||||
function flipType(type: number): RelationshipType {
|
||||
if (type === RelationshipType.FriendRequestSent) return RelationshipType.FriendRequestReceived
|
||||
if (type === RelationshipType.FriendRequestReceived) return RelationshipType.FriendRequestSent
|
||||
return type as RelationshipType
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a stored row into the RelationshipResponse for `playerId` (who must be
|
||||
* one of the pair). `PlayerID` is the *other* player; the type and the
|
||||
* favorited/ignored/muted flags are taken from `playerId`'s side of the row.
|
||||
*/
|
||||
function toResponse(row: RelationshipRow, playerId: number): RelationshipResponse {
|
||||
const isRequester = row.requester_id === playerId
|
||||
return {
|
||||
PlayerID: isRequester ? row.target_id : row.requester_id,
|
||||
RelationshipType: isRequester ? (row.relationship_type as RelationshipType) : flipType(row.relationship_type),
|
||||
Favorited: isRequester ? row.requester_favorited : row.target_favorited,
|
||||
Ignored: isRequester ? row.requester_ignored : row.target_ignored,
|
||||
Muted: isRequester ? row.requester_muted : row.target_muted,
|
||||
}
|
||||
}
|
||||
|
||||
/** Project a written row for both players in the pair. */
|
||||
function toChange(
|
||||
row: RelationshipRow,
|
||||
playerId: number,
|
||||
otherId: number,
|
||||
changed: boolean
|
||||
): RelationshipChange {
|
||||
return { self: toResponse(row, playerId), other: toResponse(row, otherId), changed }
|
||||
}
|
||||
|
||||
/** Find the single row for an unordered pair (either direction), or null. */
|
||||
async function findPair(db: D1Database, a: number, b: number): Promise<RelationshipRow | null> {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT * FROM relationship
|
||||
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
|
||||
)
|
||||
.bind(a, b)
|
||||
.first<RelationshipRow>()
|
||||
}
|
||||
|
||||
/**
|
||||
* All of a player's relationships, projected from that player's point of view.
|
||||
*
|
||||
* `None` rows are included: they are how an unfriending, or an ignore/mute of someone you
|
||||
* were never friends with, is recorded, and they still carry that player's
|
||||
* favorited/ignored/muted flags. Dropping them would lose the flags on the client.
|
||||
*/
|
||||
export async function getRelationshipsForPlayer(
|
||||
db: D1Database,
|
||||
playerId: number
|
||||
): Promise<RelationshipResponse[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT * FROM relationship
|
||||
WHERE requester_id = ?1 OR target_id = ?1`
|
||||
)
|
||||
.bind(playerId)
|
||||
.all<RelationshipRow>()
|
||||
return results.map((row) => toResponse(row, playerId))
|
||||
}
|
||||
|
||||
/**
|
||||
* The ids of everyone a player is actually friends with — `Friend` rows only, from
|
||||
* either side of the pair (the row records one direction, the friendship is mutual).
|
||||
* Pending requests and `None` rows are excluded, unlike
|
||||
* {@link getRelationshipsForPlayer}, which reports the whole graph.
|
||||
*/
|
||||
export async function getFriendIds(db: D1Database, playerId: number): Promise<number[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT CASE WHEN requester_id = ?1 THEN target_id ELSE requester_id END AS id
|
||||
FROM relationship
|
||||
WHERE relationship_type = ?2 AND (requester_id = ?1 OR target_id = ?1)`
|
||||
)
|
||||
.bind(playerId, RelationshipType.Friend)
|
||||
.all<{ id: number }>()
|
||||
return results.map((r) => r.id)
|
||||
}
|
||||
|
||||
/** How many mutual friends the mutual-friends lookup will return at most. */
|
||||
export const MUTUAL_FRIENDS_LIMIT = 100
|
||||
|
||||
/**
|
||||
* The ids two players are both friends with — the intersection of their friend lists,
|
||||
* ascending and capped at {@link MUTUAL_FRIENDS_LIMIT}. Each player's friends are a
|
||||
* small set, so the intersection is done in memory rather than as a SQL INTERSECT.
|
||||
*/
|
||||
export async function getMutualFriendIds(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
otherId: number
|
||||
): Promise<number[]> {
|
||||
const [mine, theirs] = await Promise.all([
|
||||
getFriendIds(db, playerId),
|
||||
getFriendIds(db, otherId),
|
||||
])
|
||||
const ours = new Set(theirs)
|
||||
return mine
|
||||
.filter((id) => ours.has(id))
|
||||
.sort((a, b) => a - b)
|
||||
.slice(0, MUTUAL_FRIENDS_LIMIT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist `type` for the pair, with `requesterId` recorded as the row's
|
||||
* requester. Inserts a new row or, if one already exists for the pair (either
|
||||
* direction), rewrites it so the requester is normalized to `requesterId` and
|
||||
* the flags are preserved for whichever side each player is on. Returns the
|
||||
* row as written, for the caller to project onto whichever side it needs.
|
||||
*/
|
||||
async function upsertPair(
|
||||
db: D1Database,
|
||||
requesterId: number,
|
||||
targetId: number,
|
||||
type: RelationshipType
|
||||
): Promise<RelationshipRow> {
|
||||
const existing = await findPair(db, requesterId, targetId)
|
||||
if (!existing) {
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO relationship (requester_id, target_id, relationship_type)
|
||||
VALUES (?1, ?2, ?3)`
|
||||
)
|
||||
.bind(requesterId, targetId, type)
|
||||
.run()
|
||||
return {
|
||||
requester_id: requesterId,
|
||||
target_id: targetId,
|
||||
relationship_type: type,
|
||||
requester_favorited: 0,
|
||||
requester_ignored: 0,
|
||||
requester_muted: 0,
|
||||
target_favorited: 0,
|
||||
target_ignored: 0,
|
||||
target_muted: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Keep each player's flags with that player as the row is normalized to
|
||||
// requester = requesterId.
|
||||
const reqIsRequester = existing.requester_id === requesterId
|
||||
const reqFlags = {
|
||||
favorited: reqIsRequester ? existing.requester_favorited : existing.target_favorited,
|
||||
ignored: reqIsRequester ? existing.requester_ignored : existing.target_ignored,
|
||||
muted: reqIsRequester ? existing.requester_muted : existing.target_muted,
|
||||
}
|
||||
const tgtFlags = {
|
||||
favorited: reqIsRequester ? existing.target_favorited : existing.requester_favorited,
|
||||
ignored: reqIsRequester ? existing.target_ignored : existing.requester_ignored,
|
||||
muted: reqIsRequester ? existing.target_muted : existing.requester_muted,
|
||||
}
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE relationship
|
||||
SET requester_id = ?1, target_id = ?2, relationship_type = ?3,
|
||||
requester_favorited = ?4, requester_ignored = ?5, requester_muted = ?6,
|
||||
target_favorited = ?7, target_ignored = ?8, target_muted = ?9
|
||||
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
|
||||
)
|
||||
.bind(
|
||||
requesterId,
|
||||
targetId,
|
||||
type,
|
||||
reqFlags.favorited,
|
||||
reqFlags.ignored,
|
||||
reqFlags.muted,
|
||||
tgtFlags.favorited,
|
||||
tgtFlags.ignored,
|
||||
tgtFlags.muted
|
||||
)
|
||||
.run()
|
||||
return {
|
||||
requester_id: requesterId,
|
||||
target_id: targetId,
|
||||
relationship_type: type,
|
||||
requester_favorited: reqFlags.favorited,
|
||||
requester_ignored: reqFlags.ignored,
|
||||
requester_muted: reqFlags.muted,
|
||||
target_favorited: tgtFlags.favorited,
|
||||
target_ignored: tgtFlags.ignored,
|
||||
target_muted: tgtFlags.muted,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a friend request from `requesterId` to `targetId`. If the target already
|
||||
* has a pending request out to the requester, the two become friends instead
|
||||
* (the request crosses an existing one). Already-friends, and re-sending a request
|
||||
* that's already outstanding, are no-ops.
|
||||
*/
|
||||
export async function sendFriendRequest(
|
||||
db: D1Database,
|
||||
requesterId: number,
|
||||
targetId: number
|
||||
): Promise<RelationshipChange> {
|
||||
const existing = await findPair(db, requesterId, targetId)
|
||||
if (existing) {
|
||||
// Already friends, or we already have a request out to them — nothing to write.
|
||||
if (
|
||||
existing.relationship_type === RelationshipType.Friend ||
|
||||
(existing.requester_id === requesterId &&
|
||||
existing.relationship_type === RelationshipType.FriendRequestSent)
|
||||
) {
|
||||
return toChange(existing, requesterId, targetId, false)
|
||||
}
|
||||
// The target already requested us → crossing requests become a friendship.
|
||||
if (
|
||||
existing.requester_id === targetId &&
|
||||
existing.relationship_type === RelationshipType.FriendRequestSent
|
||||
) {
|
||||
const row = await upsertPair(db, requesterId, targetId, RelationshipType.Friend)
|
||||
return toChange(row, requesterId, targetId, true)
|
||||
}
|
||||
}
|
||||
const row = await upsertPair(db, requesterId, targetId, RelationshipType.FriendRequestSent)
|
||||
return toChange(row, requesterId, targetId, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* `accepterId` accepts a pending friend request from `otherId`. Only upgrades to
|
||||
* Friend when a request from `otherId` is actually pending; otherwise the current
|
||||
* state is returned as a no-op. (The reference server answers 403 there instead;
|
||||
* we stay lenient, but either way nothing changed.)
|
||||
*/
|
||||
export async function acceptFriendRequest(
|
||||
db: D1Database,
|
||||
accepterId: number,
|
||||
otherId: number
|
||||
): Promise<RelationshipChange> {
|
||||
const existing = await findPair(db, accepterId, otherId)
|
||||
if (
|
||||
existing &&
|
||||
existing.requester_id === otherId &&
|
||||
existing.relationship_type === RelationshipType.FriendRequestSent
|
||||
) {
|
||||
const row = await upsertPair(db, otherId, accepterId, RelationshipType.Friend)
|
||||
return toChange(row, accepterId, otherId, true)
|
||||
}
|
||||
return existing
|
||||
? toChange(existing, accepterId, otherId, false)
|
||||
: { self: noneResponse(otherId), other: noneResponse(accepterId), changed: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Directly make `requesterId` and `targetId` friends (no pending request step).
|
||||
*/
|
||||
export async function addFriend(
|
||||
db: D1Database,
|
||||
requesterId: number,
|
||||
targetId: number
|
||||
): Promise<RelationshipChange> {
|
||||
const existing = await findPair(db, requesterId, targetId)
|
||||
if (existing && existing.relationship_type === RelationshipType.Friend) {
|
||||
return toChange(existing, requesterId, targetId, false)
|
||||
}
|
||||
const row = await upsertPair(db, requesterId, targetId, RelationshipType.Friend)
|
||||
return toChange(row, requesterId, targetId, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove any relationship between the two players (unfriend / cancel request /
|
||||
* decline).
|
||||
*
|
||||
* The row is set to `None` rather than deleted, matching the reference server: the
|
||||
* per-player favorited/ignored/muted flags live on that row and must survive an
|
||||
* unfriending (someone you ignored stays ignored after you drop them as a friend).
|
||||
*/
|
||||
export async function removeFriend(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
otherId: number
|
||||
): Promise<RelationshipChange> {
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE relationship SET relationship_type = ?3
|
||||
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
|
||||
)
|
||||
.bind(playerId, otherId, RelationshipType.None)
|
||||
.run()
|
||||
const updated = await findPair(db, playerId, otherId)
|
||||
return updated
|
||||
? toChange(updated, playerId, otherId, true)
|
||||
: { self: noneResponse(otherId), other: noneResponse(playerId), changed: true }
|
||||
}
|
||||
|
||||
/** A per-player relationship flag — each is stored on the player's own side of the row. */
|
||||
export type RelationshipFlag = 'favorited' | 'ignored' | 'muted'
|
||||
|
||||
/**
|
||||
* Set one of `playerId`'s per-side flags (favorited/ignored/muted) on their
|
||||
* relationship with `otherId`. These flags are stored per player, so the write
|
||||
* targets the caller's OWN side of the row — `requester_*` when the caller
|
||||
* initiated the pair, `target_*` otherwise. When the pair has no relationship yet
|
||||
* (you can ignore/mute someone you aren't friends with) a fresh `None` row is
|
||||
* created with the caller as requester. Returns the relationship from `playerId`'s
|
||||
* point of view. The `flag`/side names are a fixed union, so interpolating them
|
||||
* into the SQL is safe (same pattern as the room interaction toggles).
|
||||
*/
|
||||
export async function setRelationshipFlag(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
otherId: number,
|
||||
flag: RelationshipFlag,
|
||||
value: boolean
|
||||
): Promise<RelationshipResponse> {
|
||||
const existing = await findPair(db, playerId, otherId)
|
||||
const v = value ? 1 : 0
|
||||
if (!existing) {
|
||||
// New row: the caller is the requester, so the flag lives on the requester side.
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO relationship (requester_id, target_id, relationship_type, requester_${flag})
|
||||
VALUES (?1, ?2, ?3, ?4)`
|
||||
)
|
||||
.bind(playerId, otherId, RelationshipType.None, v)
|
||||
.run()
|
||||
} else {
|
||||
// Update whichever side the caller is on, leaving the other player's flag alone.
|
||||
const side = existing.requester_id === playerId ? 'requester' : 'target'
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE relationship SET ${side}_${flag} = ?3
|
||||
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
|
||||
)
|
||||
.bind(playerId, otherId, v)
|
||||
.run()
|
||||
}
|
||||
|
||||
const updated = await findPair(db, playerId, otherId)
|
||||
return updated ? toResponse(updated, playerId) : noneResponse(otherId)
|
||||
}
|
||||
@@ -14,13 +14,12 @@ import {
|
||||
KeepsakeConfig,
|
||||
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<App>({ 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<App>({ 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 })
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user