From 6ab803c8a497a4abbee94fc0a1e914f7ae141cf1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898283+github-actions[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:57:11 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20portfolios=20partag=C3=A9s=20entre?= =?UTF-8?q?=20amis=20(API,=20r=C3=A8gles,=20migration)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/routes/portfolios.ts | 25 + apps/api/src/services/notifications.ts | 4 +- apps/api/src/services/portfolios.ts | 318 +- apps/api/src/services/privacy.ts | 2 + apps/api/src/services/social.ts | 4 +- apps/api/test/portfolios.test.ts | 125 +- .../db/migrations/0052_portfolio_members.sql | 12 + .../db/migrations/meta/0052_snapshot.json | 8133 +++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/src/schema.ts | 21 + packages/game/src/portfolios.test.ts | 28 + packages/game/src/portfolios.ts | 30 + packages/shared/src/dto.ts | 11 +- 13 files changed, 8680 insertions(+), 40 deletions(-) create mode 100644 packages/db/migrations/0052_portfolio_members.sql create mode 100644 packages/db/migrations/meta/0052_snapshot.json diff --git a/apps/api/src/routes/portfolios.ts b/apps/api/src/routes/portfolios.ts index cdc3322..cf6911f 100644 --- a/apps/api/src/routes/portfolios.ts +++ b/apps/api/src/routes/portfolios.ts @@ -4,6 +4,7 @@ import { PORTFOLIO_DESCRIPTION_MAX, PORTFOLIO_NAME_MAX, PORTFOLIO_PROFILE_SLOTS, + PORTFOLIO_VISIBILITIES, } from "@palacards/game"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; @@ -19,9 +20,13 @@ import { designCatalog, getPortfolio, getPortfolioBackground, + invitePortfolioMember, + joinPortfolio, likePortfolio, myPortfolios, + portfolioInvitations, profilePortfolios, + removePortfolioMember, savePortfolioBackground, setPortfolioCards, setProfilePortfolios, @@ -49,6 +54,7 @@ export function portfolioRoutes(api: FastifyInstance, ctx: Ctx) { return communityPortfolios(ctx, req.user.id, q.sort, q.page); }); api.get("/portfolios/mine", auth, async (req) => myPortfolios(ctx, req.user.id)); + api.get("/portfolios/invitations", auth, async (req) => portfolioInvitations(ctx, req.user.id)); api.get("/portfolios/designs", auth, async (req) => designCatalog(ctx, req.user.id)); api.post("/portfolios/designs/:key/buy", limited(20), async (req) => { const { key } = parse(z.object({ key: z.string().min(1).max(40) }), req.params); @@ -82,6 +88,7 @@ export function portfolioRoutes(api: FastifyInstance, ctx: Ctx) { background: z.string().min(1).max(40).optional(), frame: z.string().min(1).max(40).optional(), accent: z.enum(PORTFOLIO_ACCENT_KEYS).optional(), + visibility: z.enum(PORTFOLIO_VISIBILITIES).optional(), }) .refine((v) => Object.keys(v).length > 0, "Rien à modifier"), req.body, @@ -107,6 +114,24 @@ export function portfolioRoutes(api: FastifyInstance, ctx: Ctx) { await deletePortfolio(ctx, req.user.id, parse(idParams, req.params).id); return { ok: true }; }); + // Portfolios partagés (suggestion n° 125) : invitation d'un ami, acceptation, départ ou retrait. + const memberParams = idParams.extend({ username: z.string().min(1).max(30) }); + api.post("/portfolios/:id/members", limited(30), async (req) => { + const { id } = parse(idParams, req.params); + const { username } = parse(z.object({ username: z.string().min(1).max(30) }), req.body); + await invitePortfolioMember(ctx, req.user.id, id, (await findUserByName(ctx.db, username)).id); + return getPortfolio(ctx, req.user.id, id); + }); + api.delete("/portfolios/:id/members/:username", auth, async (req) => { + const { id, username } = parse(memberParams, req.params); + await removePortfolioMember(ctx, req.user.id, id, (await findUserByName(ctx.db, username)).id); + return { ok: true }; + }); + api.post("/portfolios/:id/join", limited(30), async (req) => { + const { id } = parse(idParams, req.params); + await joinPortfolio(ctx, req.user.id, id); + return getPortfolio(ctx, req.user.id, id); + }); api.post("/portfolios/:id/like", limited(60), async (req) => likePortfolio(ctx, req.user.id, parse(idParams, req.params).id, true), ); diff --git a/apps/api/src/services/notifications.ts b/apps/api/src/services/notifications.ts index bd938db..2035506 100644 --- a/apps/api/src/services/notifications.ts +++ b/apps/api/src/services/notifications.ts @@ -15,6 +15,8 @@ export type NotificationType = | "trade_expired" | "friend_request" | "friend_accepted" + | "portfolio_invite" + | "portfolio_joined" | "wishlist_listed" | "battle_challenge" | "battle_result" @@ -37,7 +39,7 @@ export type NotificationType = export const NOTIFICATION_GROUPS: Record = { market: ["outbid", "auction_won", "auction_sold", "auction_expired", "wishlist_listed"], trades: ["trade_received", "trade_accepted", "trade_declined", "trade_countered", "trade_expired"], - social: ["friend_request", "friend_accepted", "guild_objective"], + social: ["friend_request", "friend_accepted", "portfolio_invite", "portfolio_joined", "guild_objective"], battles: ["battle_challenge", "battle_result"], packs: ["packs_full", "gift"], achievements: ["achievement", "achievement_backfill", "title_won"], diff --git a/apps/api/src/services/portfolios.ts b/apps/api/src/services/portfolios.ts index 459d6e6..5828702 100644 --- a/apps/api/src/services/portfolios.ts +++ b/apps/api/src/services/portfolios.ts @@ -1,4 +1,4 @@ -import { and, asc, desc, eq, inArray, isNotNull, schema, sql } from "@palacards/db"; +import { and, asc, desc, eq, inArray, isNotNull, isNull, schema, sql } from "@palacards/db"; import { eligibleDesigns, normalizePortfolioCards, @@ -8,7 +8,10 @@ import { PORTFOLIO_IMAGE_BACKGROUND, PORTFOLIO_MAX, PORTFOLIO_PROFILE_SLOTS, + PORTFOLIO_SHARED_MAX_PLAYERS, + portfolioCardsChange, type PortfolioAccent, + type PortfolioVisibility, } from "@palacards/game"; import { BANNER_IMAGE_MAX_BYTES, @@ -22,6 +25,7 @@ import { badRequest, conflict, forbidden, notFound } from "../errors.js"; import { sniffImage } from "./avatars.js"; import { BANNER_MAX_SIDE } from "./banners.js"; import { instanceColumns, toCardDTO } from "./cards.js"; +import { Effects } from "./notifications.js"; import { lockPlayer, movePw, pushWallet, type DbOrTx } from "./players.js"; type Tx = Parameters[0]>[0]; @@ -31,6 +35,7 @@ const pf = schema.portfolios; const pc = schema.portfolioCards; const pl = schema.portfolioLikes; const pb = schema.portfolioBackgrounds; +const pm = schema.portfolioMembers; const ci = schema.cardInstances; /** Cartes montrées dans un aperçu (liste, profil). */ @@ -43,17 +48,24 @@ const versionOf = (d: Date) => d.getTime().toString(36); // --------------------------------------------------------------------------- /** - * Cartes de ces portfolios, dans l'ordre, encore possédées par leur auteur (une carte qui change de main en sort à - * la transmission ; le filtre sur le propriétaire n'est qu'un garde-fou). Vues seulement pour l'auteur. + * La carte est à un participant du portfolio : son propriétaire ou un ami qui l'a rejoint. Une carte qui change de + * main en sort à la transmission, celles d'un ami qui part à son départ : ce filtre n'est qu'un garde-fou. */ +const heldByParticipant = sql`(${ci.ownerId} = ${pf.userId} or exists ( + select 1 from portfolio_members m + where m.portfolio_id = ${pf.id} and m.user_id = ${ci.ownerId} and m.joined_at is not null +))`; + +/** Cartes de ces portfolios, dans l'ordre, encore à un participant. Vues seulement pour celui à qui est la carte. */ async function cardsOf(db: DbOrTx, list: Portfolio[], viewerId: string, limit?: number) { const out = new Map(list.map((p) => [p.id, []])); if (!list.length) return out; const rows = await db - .select({ ...instanceColumns, portfolioId: pc.portfolioId, featured: pc.featured }) + .select({ ...instanceColumns, portfolioId: pc.portfolioId, featured: pc.featured, by: schema.user.username }) .from(pc) .innerJoin(pf, eq(pf.id, pc.portfolioId)) - .innerJoin(ci, and(eq(ci.id, pc.instanceId), eq(ci.ownerId, pf.userId))) + .innerJoin(ci, and(eq(ci.id, pc.instanceId), heldByParticipant)) + .innerJoin(schema.user, eq(schema.user.id, ci.ownerId)) .innerJoin(schema.cards, and(eq(schema.cards.season, ci.season), eq(schema.cards.id, ci.cardId))) .leftJoin(schema.wikiSummaries, eq(schema.wikiSummaries.pageId, ci.cardId)) .where( @@ -63,21 +75,47 @@ async function cardsOf(db: DbOrTx, list: Portfolio[], viewerId: string, limit?: ), ) .orderBy(asc(pc.portfolioId), asc(pc.pos)); - for (const { portfolioId, featured, ...r } of rows) - out.get(portfolioId)!.push({ ...toCardDTO(r, {}, viewerId), featured }); + for (const { portfolioId, featured, by, ...r } of rows) + out.get(portfolioId)!.push({ ...toCardDTO(r, {}, viewerId), featured, by: by ?? "" }); if (limit !== undefined) for (const [id, cards] of out) out.set(id, [...cards.filter((c) => c.featured), ...cards.filter((c) => !c.featured)].slice(0, limit)); return out; } -/** Résumés de ces portfolios pour `viewerId` (auteur, likes, aperçu ou toutes les cartes). */ +type MemberRow = { + portfolio_id: string; + user_id: string; + username: string; + display_name: string; + avatar: string | null; + joined_at: Date | string | null; +}; + +/** Participants et invités de ces portfolios, dans l'ordre d'arrivée (les invitations en attente à la fin). */ +async function membersOf(db: DbOrTx, ids: number[]) { + if (!ids.length) return []; + return db.execute(sql` + select m.portfolio_id, m.user_id, u.username, coalesce(u.display_username, u.name) as display_name, p.avatar, + m.joined_at + from portfolio_members m join "user" u on u.id = m.user_id join players p on p.user_id = u.id + where m.portfolio_id in (${sql.join( + ids.map((id) => sql`${id}`), + sql`, `, + )}) + order by m.joined_at asc nulls last, m.invited_at asc + `); +} + +const toOwnerDTO = (m: MemberRow) => ({ username: m.username, displayName: m.display_name, avatar: m.avatar }); + +/** Résumés de ces portfolios pour `viewerId` (auteur, participants, likes, aperçu ou toutes les cartes). */ async function summaries(ctx: Ctx, list: Portfolio[], viewerId: string, full: true): Promise; async function summaries(ctx: Ctx, list: Portfolio[], viewerId: string, full?: false): Promise; async function summaries(ctx: Ctx, list: Portfolio[], viewerId: string, full = false) { if (!list.length) return []; const ids = list.map((p) => p.id); - const [owners, likes, mineLiked, counts, backgrounds, allCards] = await Promise.all([ + const [owners, likes, mineLiked, counts, backgrounds, allCards, members] = await Promise.all([ ctx.db.execute<{ id: string; username: string; display_name: string; avatar: string | null }>(sql` select u.id, u.username, coalesce(u.display_username, u.name) as display_name, p.avatar from "user" u join players p on p.user_id = u.id @@ -98,12 +136,13 @@ async function summaries(ctx: Ctx, list: Portfolio[], viewerId: string, full = f ctx.db .select({ id: pc.portfolioId, n: sql`count(*)::int` }) .from(pc) - .innerJoin(ci, eq(ci.id, pc.instanceId)) - .innerJoin(pf, and(eq(pf.id, pc.portfolioId), eq(pf.userId, ci.ownerId))) + .innerJoin(pf, eq(pf.id, pc.portfolioId)) + .innerJoin(ci, and(eq(ci.id, pc.instanceId), heldByParticipant)) .where(inArray(pc.portfolioId, ids)) .groupBy(pc.portfolioId), ctx.db.select({ id: pb.portfolioId, updatedAt: pb.updatedAt }).from(pb).where(inArray(pb.portfolioId, ids)), cardsOf(ctx.db, list, viewerId, full ? undefined : PREVIEW_CARDS), + membersOf(ctx.db, ids), ]); const ownerBy = new Map(owners.map((o) => [o.id, o])); const likesBy = new Map(likes.map((l) => [l.id, l.n])); @@ -113,6 +152,9 @@ async function summaries(ctx: Ctx, list: Portfolio[], viewerId: string, full = f return list.map((p) => { const o = ownerBy.get(p.userId); const cards = allCards.get(p.id) ?? []; + const here = members.filter((m) => Number(m.portfolio_id) === p.id); + const me = here.find((m) => m.user_id === viewerId); + const role = p.userId === viewerId ? "owner" : me ? (me.joined_at ? "member" : "invited") : null; const summary: PortfolioSummaryDTO = { id: p.id, name: p.name, @@ -132,18 +174,26 @@ async function summaries(ctx: Ctx, list: Portfolio[], viewerId: string, full = f preview: full ? [...cards.filter((c) => c.featured), ...cards.filter((c) => !c.featured)].slice(0, PREVIEW_CARDS) : cards, + visibility: p.visibility, + role, + members: here.filter((m) => m.joined_at).map(toOwnerDTO), createdAt: p.createdAt.toISOString(), updatedAt: p.updatedAt.toISOString(), }; - return full ? { ...summary, cards } : summary; + if (!full) return summary; + const invited = role === "owner" ? here.filter((m) => !m.joined_at).map(toOwnerDTO) : []; + return { ...summary, cards, invited }; }); } -/** Portfolio non vide (une carte encore possédée) : visible des autres joueurs. */ -const nonEmpty = sql`exists ( +/** Portfolio public et non vide (une carte encore à un participant) : visible des autres joueurs. */ +const isPublic = sql`(${pf.visibility} = 'public' and exists ( select 1 from portfolio_cards x join card_instances i on i.id = x.instance_id - where x.portfolio_id = ${pf.id} and i.owner_id = ${pf.userId} -)`; + where x.portfolio_id = ${pf.id} and (i.owner_id = ${pf.userId} or exists ( + select 1 from portfolio_members m + where m.portfolio_id = ${pf.id} and m.user_id = i.owner_id and m.joined_at is not null + )) +))`; async function load(db: DbOrTx, id: number): Promise { const [p] = await db.select().from(pf).where(eq(pf.id, id)); @@ -151,6 +201,16 @@ async function load(db: DbOrTx, id: number): Promise { return p; } +/** Place d'un joueur dans un portfolio : propriétaire, participant, invité ou aucune. */ +async function roleIn(db: DbOrTx, p: Portfolio, userId: string): Promise { + if (p.userId === userId) return "owner"; + const [m] = await db + .select({ joinedAt: pm.joinedAt }) + .from(pm) + .where(and(eq(pm.portfolioId, p.id), eq(pm.userId, userId))); + return m ? (m.joinedAt ? "member" : "invited") : null; +} + /** Son propre portfolio, verrouillé (modification). */ async function own(tx: Tx, userId: string, id: number): Promise { const [p] = await tx.select().from(pf).where(eq(pf.id, id)).for("update"); @@ -159,30 +219,62 @@ async function own(tx: Tx, userId: string, id: number): Promise { return p; } -/** Page d'un portfolio : son auteur le voit toujours, les autres seulement s'il a une carte. */ +/** Portfolio dont on est propriétaire ou participant, verrouillé (ses cartes). */ +async function editable(tx: Tx, userId: string, id: number) { + const [p] = await tx.select().from(pf).where(eq(pf.id, id)).for("update"); + if (!p) throw notFound("Ce portfolio n'existe pas."); + const role = await roleIn(tx, p, userId); + if (role !== "owner" && role !== "member") throw forbidden("Tu ne participes pas à ce portfolio."); + return p; +} + +/** + * Page d'un portfolio : son propriétaire, ses participants et ses invités le voient toujours ; les autres + * seulement s'il est public et a une carte. + */ export async function getPortfolio(ctx: Ctx, viewerId: string, id: number): Promise { const p = await load(ctx.db, id); const [dto] = await summaries(ctx, [p], viewerId, true); - if (p.userId !== viewerId && !dto!.cards.length) throw notFound("Ce portfolio n'existe pas."); + if (!dto!.role && (p.visibility !== "public" || !dto!.cards.length)) throw notFound("Ce portfolio n'existe pas."); return dto!; } -/** Ses portfolios : ceux du profil d'abord (dans l'ordre), puis les plus récents. */ +/** Ses portfolios et ceux qu'on partage avec un ami : ceux du profil d'abord (dans l'ordre), puis les plus récents. */ export async function myPortfolios(ctx: Ctx, userId: string): Promise { const list = await ctx.db .select() .from(pf) - .where(eq(pf.userId, userId)) - .orderBy(sql`${pf.profileSlot} asc nulls last`, desc(pf.updatedAt)); + .where( + sql`(${pf.userId} = ${userId} or exists ( + select 1 from portfolio_members m where m.portfolio_id = ${pf.id} and m.user_id = ${userId} + and m.joined_at is not null + ))`, + ) + .orderBy(sql`case when ${pf.userId} = ${userId} then ${pf.profileSlot} end asc nulls last`, desc(pf.updatedAt)); return summaries(ctx, list, userId); } -/** Portfolios du profil d'un joueur (3 au plus, dans l'ordre qu'il a choisi), non vides. */ +/** Invitations en attente dans les portfolios partagés d'amis, les plus récentes d'abord. */ +export async function portfolioInvitations(ctx: Ctx, userId: string): Promise { + const rows = await ctx.db + .select({ p: pf }) + .from(pm) + .innerJoin(pf, eq(pf.id, pm.portfolioId)) + .where(and(eq(pm.userId, userId), isNull(pm.joinedAt))) + .orderBy(desc(pm.invitedAt)); + return summaries( + ctx, + rows.map((r) => r.p), + userId, + ); +} + +/** Portfolios du profil d'un joueur (3 au plus, dans l'ordre qu'il a choisi), publics et non vides. */ export async function profilePortfolios(ctx: Ctx, viewerId: string, ownerId: string) { const list = await ctx.db .select() .from(pf) - .where(and(eq(pf.userId, ownerId), isNotNull(pf.profileSlot), nonEmpty)) + .where(and(eq(pf.userId, ownerId), isNotNull(pf.profileSlot), isPublic)) .orderBy(asc(pf.profileSlot)); return summaries(ctx, list, viewerId); } @@ -195,7 +287,7 @@ export async function communityPortfolios(ctx: Ctx, viewerId: string, sort: "rec const list = await ctx.db .select() .from(pf) - .where(nonEmpty) + .where(isPublic) .orderBy(...(sort === "likes" ? [sql`${likes} desc`, desc(pf.createdAt)] : [desc(pf.createdAt)]), desc(pf.id)) .limit(COMMUNITY_PAGE + 1) .offset(page * COMMUNITY_PAGE); @@ -229,12 +321,22 @@ export async function createPortfolio(ctx: Ctx, userId: string, input: { name: s }); } -/** Nom, description et design ; un design pas encore à soi est refusé. */ +/** + * Nom, description, design et visibilité (propriétaire seul) ; un design pas encore à soi est refusé. Réservé aux + * participants, le portfolio quitte le profil. + */ export async function updatePortfolio( ctx: Ctx, userId: string, id: number, - input: { name?: string; description?: string; background?: string; frame?: string; accent?: PortfolioAccent }, + input: { + name?: string; + description?: string; + background?: string; + frame?: string; + accent?: PortfolioAccent; + visibility?: PortfolioVisibility; + }, ) { const have = await designsOf(ctx.db, userId); await ctx.db.transaction(async (tx) => { @@ -254,12 +356,19 @@ export async function updatePortfolio( } await tx .update(pf) - .set({ ...input, updatedAt: ctx.now() }) + .set({ + ...input, + ...(input.visibility === "members" ? { profileSlot: null } : {}), + updatedAt: ctx.now(), + }) .where(eq(pf.id, p.id)); }); } -/** Remplace les cartes (ordre et vedette) : des exemplaires de sa collection seulement. */ +/** + * Remplace les cartes (ordre et vedette), par le propriétaire ou un participant. Chacun n'ajoute que des exemplaires + * de sa collection et ne retire que les siens (le propriétaire peut retirer ceux de tous) ; rien ne change de main. + */ export async function setPortfolioCards( ctx: Ctx, userId: string, @@ -269,14 +378,26 @@ export async function setPortfolioCards( const list = normalizePortfolioCards(cards); if (!list) throw badRequest("portfolio_cards", "Liste de cartes invalide (30 au plus, chacune une fois)."); await ctx.db.transaction(async (tx) => { - const p = await own(tx, userId, id); - const ids = list.map((c) => c.instanceId); - if (ids.length) { + const p = await editable(tx, userId, id); + const before = await tx + .select({ instanceId: pc.instanceId, ownerId: ci.ownerId }) + .from(pc) + .innerJoin(pf, eq(pf.id, pc.portfolioId)) + .innerJoin(ci, and(eq(ci.id, pc.instanceId), heldByParticipant)) + .where(eq(pc.portfolioId, p.id)); + const change = portfolioCardsChange({ + editorId: userId, + ownerId: p.userId, + before, + after: list.map((c) => c.instanceId), + }); + if (!change) throw forbidden("Tu ne peux retirer que tes propres cartes."); + if (change.added.length) { const owned = await tx .select({ id: ci.id }) .from(ci) - .where(and(inArray(ci.id, ids), eq(ci.ownerId, userId))); - if (owned.length !== ids.length) throw notFound("Certaines cartes ne sont plus dans ta collection."); + .where(and(inArray(ci.id, change.added), eq(ci.ownerId, userId))); + if (owned.length !== change.added.length) throw notFound("Certaines cartes ne sont plus dans ta collection."); } await tx.delete(pc).where(eq(pc.portfolioId, p.id)); if (list.length) @@ -305,7 +426,7 @@ export async function setProfilePortfolios(ctx: Ctx, userId: string, ids: number const mine = await tx .select({ id: pf.id }) .from(pf) - .where(and(inArray(pf.id, wanted), eq(pf.userId, userId))); + .where(and(inArray(pf.id, wanted), eq(pf.userId, userId), eq(pf.visibility, "public"))); if (mine.length !== wanted.length) throw notFound("Portfolio introuvable."); } // Tout est libéré d'abord : l'index unique (joueur, place) ne voit jamais deux portfolios au même endroit. @@ -318,18 +439,143 @@ export async function setProfilePortfolios(ctx: Ctx, userId: string, ids: number }); } +// --------------------------------------------------------------------------- +// Portfolios partagés (suggestion n° 125) : invitations d'amis, départ, retrait +// --------------------------------------------------------------------------- + +async function areFriends(db: DbOrTx, x: string, y: string) { + const [a, b] = x < y ? [x, y] : [y, x]; + const [row] = await db + .select({ status: schema.friendships.status }) + .from(schema.friendships) + .where(and(eq(schema.friendships.userA, a), eq(schema.friendships.userB, b))); + return row?.status === "accepted"; +} + +async function nameOf(db: DbOrTx, userId: string) { + const [u] = await db + .select({ name: sql`coalesce(${schema.user.displayUsername}, ${schema.user.name})` }) + .from(schema.user) + .where(eq(schema.user.id, userId)); + return u?.name ?? "?"; +} + +/** Le propriétaire invite un ami ; propriétaire et invitations compris, PORTFOLIO_SHARED_MAX_PLAYERS joueurs au plus. */ +export async function invitePortfolioMember(ctx: Ctx, userId: string, id: number, friendId: string) { + if (friendId === userId) throw badRequest("self", "Tu participes déjà à ton portfolio."); + const fx = new Effects(); + await ctx.db.transaction(async (tx) => { + const p = await own(tx, userId, id); + if (!(await areFriends(tx, userId, friendId))) throw forbidden("Tu ne peux inviter que tes amis."); + const rows = await tx.select({ userId: pm.userId }).from(pm).where(eq(pm.portfolioId, p.id)); + if (rows.some((r) => r.userId === friendId)) throw conflict("portfolio_member", "Cet ami est déjà invité."); + if (rows.length + 1 >= PORTFOLIO_SHARED_MAX_PLAYERS) + throw conflict( + "portfolio_members_full", + `Un portfolio partagé réunit ${PORTFOLIO_SHARED_MAX_PLAYERS} joueurs au plus, toi compris.`, + ); + await tx.insert(pm).values({ portfolioId: p.id, userId: friendId, invitedAt: ctx.now() }); + await fx.notify(tx, friendId, "portfolio_invite", { + from: await nameOf(tx, userId), + portfolioId: p.id, + name: p.name, + }); + }); + await fx.flush(ctx); +} + +/** L'ami invité accepte : il peut dès lors y poser ses cartes. Il faut être encore amis. */ +export async function joinPortfolio(ctx: Ctx, userId: string, id: number) { + const fx = new Effects(); + await ctx.db.transaction(async (tx) => { + const [p] = await tx.select().from(pf).where(eq(pf.id, id)).for("update"); + const [m] = p + ? await tx + .select() + .from(pm) + .where(and(eq(pm.portfolioId, id), eq(pm.userId, userId))) + : []; + if (!p || !m) throw notFound("Aucune invitation pour ce portfolio."); + if (m.joinedAt) throw conflict("portfolio_member", "Tu participes déjà à ce portfolio."); + if (!(await areFriends(tx, userId, p.userId))) + throw forbidden("Vous n'êtes plus amis : l'invitation ne tient plus."); + await tx + .update(pm) + .set({ joinedAt: ctx.now() }) + .where(and(eq(pm.portfolioId, id), eq(pm.userId, userId))); + // On n'aime pas un portfolio auquel on participe. + await tx.delete(pl).where(and(eq(pl.portfolioId, id), eq(pl.userId, userId))); + await fx.notify(tx, p.userId, "portfolio_joined", { + from: await nameOf(tx, userId), + portfolioId: id, + name: p.name, + }); + }); + await fx.flush(ctx); +} + +/** Sort un participant (ou une invitation) d'un portfolio : ses cartes le quittent et restent dans sa collection. */ +async function dropMember(tx: Tx, id: number, memberId: string, now: Date) { + const gone = await tx + .delete(pm) + .where(and(eq(pm.portfolioId, id), eq(pm.userId, memberId))) + .returning({ userId: pm.userId }); + if (!gone.length) return false; + await tx + .delete(pc) + .where( + and( + eq(pc.portfolioId, id), + inArray(pc.instanceId, tx.select({ id: ci.id }).from(ci).where(eq(ci.ownerId, memberId))), + ), + ); + await tx.update(pf).set({ updatedAt: now }).where(eq(pf.id, id)); + return true; +} + +/** + * Retire un participant ou une invitation : le propriétaire pour n'importe qui (révocation), chacun pour soi-même + * (départ, refus). + */ +export async function removePortfolioMember(ctx: Ctx, userId: string, id: number, memberId: string) { + await ctx.db.transaction(async (tx) => { + const [p] = await tx.select().from(pf).where(eq(pf.id, id)).for("update"); + if (!p) throw notFound("Ce portfolio n'existe pas."); + if (memberId !== userId && p.userId !== userId) throw forbidden("Ce portfolio n'est pas le tien."); + if (!(await dropMember(tx, id, memberId, ctx.now()))) throw notFound("Ce joueur ne participe pas à ce portfolio."); + }); +} + +/** Deux joueurs ne sont plus amis : chacun quitte les portfolios partagés de l'autre. */ +export async function leaveSharedPortfolios(ctx: Ctx, x: string, y: string) { + await ctx.db.transaction(async (tx) => { + for (const [ownerId, memberId] of [ + [x, y], + [y, x], + ] as const) { + const rows = await tx + .select({ id: pm.portfolioId }) + .from(pm) + .innerJoin(pf, eq(pf.id, pm.portfolioId)) + .where(and(eq(pf.userId, ownerId), eq(pm.userId, memberId))); + for (const r of rows) await dropMember(tx, r.id, memberId, ctx.now()); + } + }); +} + // --------------------------------------------------------------------------- // Likes (sans récompense) // --------------------------------------------------------------------------- export async function likePortfolio(ctx: Ctx, userId: string, id: number, like: boolean) { const p = await load(ctx.db, id); - if (p.userId === userId) throw forbidden("Tu ne peux pas aimer ton propre portfolio."); + const role = await roleIn(ctx.db, p, userId); + if (role === "owner" || role === "member") throw forbidden("Tu ne peux pas aimer ton propre portfolio."); if (like) { const [visible] = await ctx.db .select({ id: pf.id }) .from(pf) - .where(and(eq(pf.id, id), nonEmpty)); + .where(and(eq(pf.id, id), isPublic)); if (!visible) throw notFound("Ce portfolio n'existe pas."); await ctx.db.insert(pl).values({ portfolioId: id, userId, createdAt: ctx.now() }).onConflictDoNothing(); } else { diff --git a/apps/api/src/services/privacy.ts b/apps/api/src/services/privacy.ts index 04389d5..20ab3a3 100644 --- a/apps/api/src/services/privacy.ts +++ b/apps/api/src/services/privacy.ts @@ -156,6 +156,8 @@ export async function exportData(ctx: Ctx, userId: string) { .from(s.playerPortfolioDesigns) .where(eq(s.playerPortfolioDesigns.userId, userId)), portfolioLikes: await db.select().from(s.portfolioLikes).where(eq(s.portfolioLikes.userId, userId)), + // Portfolios partagés d'amis : invitations reçues et participations. + portfolioMemberships: await db.select().from(s.portfolioMembers).where(eq(s.portfolioMembers.userId, userId)), // Admin : cartes ajoutées ou retirées à la main d'un filtre d'images. imageFilterChoices: await db .select() diff --git a/apps/api/src/services/social.ts b/apps/api/src/services/social.ts index dea1ebb..584b8f7 100644 --- a/apps/api/src/services/social.ts +++ b/apps/api/src/services/social.ts @@ -3,6 +3,7 @@ import { dmChannel, guildChannel, type Rarity } from "@palacards/game"; import type { Ctx } from "../context.js"; import { badRequest, conflict, forbidden, notFound } from "../errors.js"; import { Effects } from "./notifications.js"; +import { leaveSharedPortfolios } from "./portfolios.js"; import { emit } from "./progression.js"; import { findUserByName, statusNoteOf } from "./profiles.js"; @@ -86,10 +87,11 @@ export async function acceptFriend(ctx: Ctx, userId: string, otherId: string) { } } -/** Refuse, annule une demande ou retire un ami. */ +/** Refuse, annule une demande ou retire un ami (chacun quitte alors les portfolios partagés de l'autre). */ export async function removeFriend(ctx: Ctx, userId: string, otherId: string) { const key = pair(userId, otherId); await ctx.db.delete(f).where(and(eq(f.userA, key.userA), eq(f.userB, key.userB))); + await leaveSharedPortfolios(ctx, userId, otherId); } export async function listFriends(ctx: Ctx, userId: string) { diff --git a/apps/api/test/portfolios.test.ts b/apps/api/test/portfolios.test.ts index 87aab9c..e5d5f5b 100644 --- a/apps/api/test/portfolios.test.ts +++ b/apps/api/test/portfolios.test.ts @@ -1,5 +1,5 @@ import { eq, schema, sql } from "@palacards/db"; -import { ECONOMY, PORTFOLIO_MAX, PORTFOLIO_PRICES } from "@palacards/game"; +import { ECONOMY, PORTFOLIO_MAX, PORTFOLIO_PRICES, PORTFOLIO_SHARED_MAX_PLAYERS } from "@palacards/game"; import { afterAll, describe, expect, it } from "vitest"; import { makeApp, signUp, signUpAdmin, type Client } from "./helpers.js"; @@ -191,3 +191,126 @@ describe("portfolios (suggestions n° 3 et 116)", () => { expect(exported.portfolioCards).toEqual([expect.objectContaining({ portfolioId: id, instanceId: card })]); }); }); + +describe("portfolios partagés (suggestion n° 125)", () => { + async function befriend(a: Client, b: Client) { + await a.post("/friends", { username: b.username }); + await b.post("/friends", { username: a.username }); + } + const ids = (body: { cards: { instanceId: number }[] }) => body.cards.map((c) => c.instanceId); + + it("invite un ami, qui y pose ses propres cartes sans qu'aucune change de main", async () => { + const owner = await signUp(app); + const friend = await signUp(app); + const stranger = await signUp(app); + await befriend(owner, friend); + const [o1] = await withCards(owner); + const [f1, f2] = await withCards(friend); + const id = await create(owner, "À deux"); + await owner.put(`/portfolios/${id}/cards`, { cards: [{ instanceId: o1 }] }); + + // Seuls les amis peuvent être invités ; l'invité est prévenu et voit l'invitation. + expect((await owner.post(`/portfolios/${id}/members`, { username: stranger.username })).status).toBe(403); + const invited = await owner.post(`/portfolios/${id}/members`, { username: friend.username }); + expect(invited.status).toBe(200); + expect(invited.body.invited.map((m: { username: string }) => m.username)).toEqual([friend.username]); + expect((await owner.post(`/portfolios/${id}/members`, { username: friend.username })).status).toBe(409); + expect((await friend.get("/notifications")).body.items[0].type).toBe("portfolio_invite"); + const invitations = await friend.get("/portfolios/invitations"); + expect(invitations.body.map((p: { id: number; role: string }) => [p.id, p.role])).toEqual([[id, "invited"]]); + + // Pas encore participant : il ne peut rien y poser. + expect((await friend.put(`/portfolios/${id}/cards`, { cards: [{ instanceId: o1 }] })).status).toBe(403); + const joined = await friend.post(`/portfolios/${id}/join`); + expect(joined.body.role).toBe("member"); + expect(joined.body.invited).toEqual([]); + expect((await owner.get("/notifications")).body.items[0].type).toBe("portfolio_joined"); + expect((await friend.get("/portfolios/mine")).body.map((p: { id: number }) => p.id)).toContain(id); + + // L'ami ajoute ses cartes ; celle d'un autre est refusée, comme le retrait d'une carte qui n'est pas à lui. + const added = await friend.put(`/portfolios/${id}/cards`, { + cards: [{ instanceId: f1 }, { instanceId: o1 }, { instanceId: f2, featured: true }], + }); + expect(added.status).toBe(200); + expect(added.body.cards.map((c: { by: string }) => c.by)).toEqual([ + friend.username, + owner.username, + friend.username, + ]); + const [s1] = await withCards(stranger); + expect( + (await friend.put(`/portfolios/${id}/cards`, { cards: [...added.body.cards, { instanceId: s1 }] })).status, + ).toBe(404); + expect((await friend.put(`/portfolios/${id}/cards`, { cards: [{ instanceId: f1 }] })).status).toBe(403); + // Il ne touche ni au nom ni au design, et n'aime pas un portfolio auquel il participe. + expect((await friend.patch(`/portfolios/${id}`, { name: "Volé" })).status).toBe(403); + expect((await friend.post(`/portfolios/${id}/like`)).status).toBe(403); + const [row] = await ctx.db.select().from(schema.cardInstances).where(eq(schema.cardInstances.id, f1!)); + expect(row!.ownerId).toBe(friend.userId); + + // Le propriétaire peut retirer la carte de son ami ; l'ami retire la sienne. + const trimmed = await owner.put(`/portfolios/${id}/cards`, { cards: [{ instanceId: o1 }, { instanceId: f2 }] }); + expect(ids(trimmed.body)).toEqual([o1, f2]); + + // Révocation : l'ami perd l'accès et ses cartes quittent le portfolio (elles restent à lui). + expect((await owner.del(`/portfolios/${id}/members/${friend.username}`)).status).toBe(200); + expect(ids((await owner.get(`/portfolios/${id}`)).body)).toEqual([o1]); + expect((await friend.put(`/portfolios/${id}/cards`, { cards: [{ instanceId: o1 }] })).status).toBe(403); + expect((await friend.get("/portfolios/mine")).body.map((p: { id: number }) => p.id)).not.toContain(id); + }); + + it("limite à 5 joueurs, laisse partir un participant et retire les anciens amis", async () => { + const owner = await signUp(app); + const id = await create(owner); + const friends: Client[] = []; + for (let i = 0; i < PORTFOLIO_SHARED_MAX_PLAYERS; i++) { + const f = await signUp(app); + await befriend(owner, f); + friends.push(f); + } + for (const f of friends.slice(0, PORTFOLIO_SHARED_MAX_PLAYERS - 1)) + expect((await owner.post(`/portfolios/${id}/members`, { username: f.username })).status).toBe(200); + const full = await owner.post(`/portfolios/${id}/members`, { username: friends.at(-1)!.username }); + expect(full.body.error).toBe("portfolio_members_full"); + + // Un invité refuse (ou un participant part) : il se retire lui-même ; il ne retire pas les autres. + const [a, b, c] = friends; + expect((await a!.del(`/portfolios/${id}/members/${a!.username}`)).status).toBe(200); + expect((await b!.del(`/portfolios/${id}/members/${c!.username}`)).status).toBe(403); + await b!.post(`/portfolios/${id}/join`); + const [card] = await withCards(b!); + await b!.put(`/portfolios/${id}/cards`, { cards: [{ instanceId: card }] }); + expect((await owner.get(`/portfolios/${id}`)).body.cardCount).toBe(1); + + // Plus amis : il quitte le portfolio, avec ses cartes. + expect((await owner.del(`/friends/${b!.userId}`)).status).toBe(200); + const after = await owner.get(`/portfolios/${id}`); + expect(after.body.members).toEqual([]); + expect(after.body.cardCount).toBe(0); + }); + + it("cache aux autres un portfolio réservé à ses participants", async () => { + const owner = await signUp(app); + const friend = await signUp(app); + const visitor = await signUp(app); + await befriend(owner, friend); + const [card] = await withCards(owner); + const id = await create(owner, "Entre nous"); + await owner.put(`/portfolios/${id}/cards`, { cards: [{ instanceId: card }] }); + await owner.put("/portfolios/profile", { ids: [id] }); + await owner.post(`/portfolios/${id}/members`, { username: friend.username }); + expect((await visitor.get(`/portfolios/${id}`)).status).toBe(200); + + const hidden = await owner.patch(`/portfolios/${id}`, { visibility: "members" }); + expect(hidden.body.visibility).toBe("members"); + expect(hidden.body.profileSlot).toBeNull(); + expect((await visitor.get(`/portfolios/${id}`)).status).toBe(404); + expect((await visitor.post(`/portfolios/${id}/like`)).status).toBe(404); + expect((await visitor.get(`/players/${owner.username}/portfolios`)).body).toEqual([]); + const community = await visitor.get("/portfolios"); + expect(community.body.items.map((p: { id: number }) => p.id)).not.toContain(id); + expect((await owner.put("/portfolios/profile", { ids: [id] })).status).toBe(404); + // L'invité le voit, pour décider. + expect((await friend.get(`/portfolios/${id}`)).status).toBe(200); + }); +}); diff --git a/packages/db/migrations/0052_portfolio_members.sql b/packages/db/migrations/0052_portfolio_members.sql new file mode 100644 index 0000000..1b007a9 --- /dev/null +++ b/packages/db/migrations/0052_portfolio_members.sql @@ -0,0 +1,12 @@ +CREATE TABLE "portfolio_members" ( + "portfolio_id" bigint NOT NULL, + "user_id" text NOT NULL, + "invited_at" timestamp with time zone DEFAULT now() NOT NULL, + "joined_at" timestamp with time zone, + CONSTRAINT "portfolio_members_portfolio_id_user_id_pk" PRIMARY KEY("portfolio_id","user_id") +); +--> statement-breakpoint +ALTER TABLE "portfolios" ADD COLUMN "visibility" text DEFAULT 'public' NOT NULL;--> statement-breakpoint +ALTER TABLE "portfolio_members" ADD CONSTRAINT "portfolio_members_portfolio_id_portfolios_id_fk" FOREIGN KEY ("portfolio_id") REFERENCES "public"."portfolios"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "portfolio_members" ADD CONSTRAINT "portfolio_members_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "portfolio_members_user_idx" ON "portfolio_members" USING btree ("user_id"); \ No newline at end of file diff --git a/packages/db/migrations/meta/0052_snapshot.json b/packages/db/migrations/meta/0052_snapshot.json new file mode 100644 index 0000000..77ea302 --- /dev/null +++ b/packages/db/migrations/meta/0052_snapshot.json @@ -0,0 +1,8133 @@ +{ + "id": "2937aaa7-a954-44f5-8617-c4305949d02a", + "prevId": "5eec94c1-b111-41f7-9ddf-67ba27d27de5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.achievements_progress": { + "name": "achievements_progress", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "achievement_key": { + "name": "achievement_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unlocked_at": { + "name": "unlocked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "achievements_progress_user_id_user_id_fk": { + "name": "achievements_progress_user_id_user_id_fk", + "tableFrom": "achievements_progress", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "achievements_progress_user_id_achievement_key_pk": { + "name": "achievements_progress_user_id_achievement_key_pk", + "columns": [ + "user_id", + "achievement_key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auctions": { + "name": "auctions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "auctions_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "instance_id": { + "name": "instance_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "seller_id": { + "name": "seller_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "season": { + "name": "season", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "rarity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "shiny": { + "name": "shiny", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "start_price": { + "name": "start_price", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "buyout": { + "name": "buyout", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "current_bid": { + "name": "current_bid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "current_bidder_id": { + "name": "current_bidder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bid_count": { + "name": "bid_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auctions_status_ends_idx": { + "name": "auctions_status_ends_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ends_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auctions_instance_idx": { + "name": "auctions_instance_idx", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auctions_open_instance_uq": { + "name": "auctions_open_instance_uq", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"auctions\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auctions_instance_id_card_instances_id_fk": { + "name": "auctions_instance_id_card_instances_id_fk", + "tableFrom": "auctions", + "tableTo": "card_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "auctions_seller_id_user_id_fk": { + "name": "auctions_seller_id_user_id_fk", + "tableFrom": "auctions", + "tableTo": "user", + "columnsFrom": [ + "seller_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auctions_current_bidder_id_user_id_fk": { + "name": "auctions_current_bidder_id_user_id_fk", + "tableFrom": "auctions", + "tableTo": "user", + "columnsFrom": [ + "current_bidder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "auctions_prices_ok": { + "name": "auctions_prices_ok", + "value": "\"auctions\".\"start_price\" > 0 AND (\"auctions\".\"buyout\" IS NULL OR \"auctions\".\"buyout\" >= \"auctions\".\"start_price\")" + } + }, + "isRLSEnabled": false + }, + "public.battle_decks": { + "name": "battle_decks", + "schema": "", + "columns": { + "battle_id": { + "name": "battle_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slot": { + "name": "slot", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "season": { + "name": "season", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "rarity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "atk": { + "name": "atk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "def": { + "name": "def", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "battle_decks_battle_id_battles_id_fk": { + "name": "battle_decks_battle_id_battles_id_fk", + "tableFrom": "battle_decks", + "tableTo": "battles", + "columnsFrom": [ + "battle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "battle_decks_user_id_user_id_fk": { + "name": "battle_decks_user_id_user_id_fk", + "tableFrom": "battle_decks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "battle_decks_battle_id_user_id_slot_pk": { + "name": "battle_decks_battle_id_user_id_slot_pk", + "columns": [ + "battle_id", + "user_id", + "slot" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "battle_decks_slot": { + "name": "battle_decks_slot", + "value": "\"battle_decks\".\"slot\" BETWEEN 1 AND 5" + } + }, + "isRLSEnabled": false + }, + "public.battle_queue": { + "name": "battle_queue", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "deck": { + "name": "deck", + "type": "bigint[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "battle_queue_user_id_user_id_fk": { + "name": "battle_queue_user_id_user_id_fk", + "tableFrom": "battle_queue", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.battle_turns": { + "name": "battle_turns", + "schema": "", + "columns": { + "battle_id": { + "name": "battle_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "turn": { + "name": "turn", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "attacker_id": { + "name": "attacker_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "defender_id": { + "name": "defender_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attack_slot": { + "name": "attack_slot", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "attack_auto": { + "name": "attack_auto", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "shield_slot": { + "name": "shield_slot", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "shield_auto": { + "name": "shield_auto", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "question": { + "name": "question", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "served_at": { + "name": "served_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "answered_at": { + "name": "answered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "choice": { + "name": "choice", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "correct": { + "name": "correct", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "answer_ms": { + "name": "answer_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "raw_damage": { + "name": "raw_damage", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "shield_pct": { + "name": "shield_pct", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "damage": { + "name": "damage", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "reflected": { + "name": "reflected", + "type": "smallint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "battle_turns_defender_idx": { + "name": "battle_turns_defender_idx", + "columns": [ + { + "expression": "defender_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "battle_turns_battle_id_battles_id_fk": { + "name": "battle_turns_battle_id_battles_id_fk", + "tableFrom": "battle_turns", + "tableTo": "battles", + "columnsFrom": [ + "battle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "battle_turns_attacker_id_user_id_fk": { + "name": "battle_turns_attacker_id_user_id_fk", + "tableFrom": "battle_turns", + "tableTo": "user", + "columnsFrom": [ + "attacker_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "battle_turns_defender_id_user_id_fk": { + "name": "battle_turns_defender_id_user_id_fk", + "tableFrom": "battle_turns", + "tableTo": "user", + "columnsFrom": [ + "defender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "battle_turns_battle_id_turn_pk": { + "name": "battle_turns_battle_id_turn_pk", + "columns": [ + "battle_id", + "turn" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "battle_turns_turn": { + "name": "battle_turns_turn", + "value": "\"battle_turns\".\"turn\" BETWEEN 1 AND 8" + } + }, + "isRLSEnabled": false + }, + "public.battles": { + "name": "battles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "battles_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "challenger_id": { + "name": "challenger_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opponent_id": { + "name": "opponent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "seed": { + "name": "seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "winner_id": { + "name": "winner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "turn": { + "name": "turn", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "phase_started_at": { + "name": "phase_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "phase_ends_at": { + "name": "phase_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "first_attacker_id": { + "name": "first_attacker_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "challenger_hp": { + "name": "challenger_hp", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "opponent_hp": { + "name": "opponent_hp", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "challenger_idle": { + "name": "challenger_idle", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "opponent_idle": { + "name": "opponent_idle", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "forfeit_by": { + "name": "forfeit_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "challenger_elo_delta": { + "name": "challenger_elo_delta", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "opponent_elo_delta": { + "name": "opponent_elo_delta", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "battles_challenger_idx": { + "name": "battles_challenger_idx", + "columns": [ + { + "expression": "challenger_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "battles_opponent_idx": { + "name": "battles_opponent_idx", + "columns": [ + { + "expression": "opponent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "battles_challenger_id_user_id_fk": { + "name": "battles_challenger_id_user_id_fk", + "tableFrom": "battles", + "tableTo": "user", + "columnsFrom": [ + "challenger_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "battles_opponent_id_user_id_fk": { + "name": "battles_opponent_id_user_id_fk", + "tableFrom": "battles", + "tableTo": "user", + "columnsFrom": [ + "opponent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "battles_winner_id_user_id_fk": { + "name": "battles_winner_id_user_id_fk", + "tableFrom": "battles", + "tableTo": "user", + "columnsFrom": [ + "winner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "battles_first_attacker_id_user_id_fk": { + "name": "battles_first_attacker_id_user_id_fk", + "tableFrom": "battles", + "tableTo": "user", + "columnsFrom": [ + "first_attacker_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "battles_forfeit_by_user_id_fk": { + "name": "battles_forfeit_by_user_id_fk", + "tableFrom": "battles", + "tableTo": "user", + "columnsFrom": [ + "forfeit_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "battles_distinct": { + "name": "battles_distinct", + "value": "\"battles\".\"challenger_id\" <> \"battles\".\"opponent_id\"" + } + }, + "isRLSEnabled": false + }, + "public.bids": { + "name": "bids", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "bids_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "auction_id": { + "name": "auction_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "bidder_id": { + "name": "bidder_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bids_auction_idx": { + "name": "bids_auction_idx", + "columns": [ + { + "expression": "auction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bids_auction_id_auctions_id_fk": { + "name": "bids_auction_id_auctions_id_fk", + "tableFrom": "bids", + "tableTo": "auctions", + "columnsFrom": [ + "auction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bids_bidder_id_user_id_fk": { + "name": "bids_bidder_id_user_id_fk", + "tableFrom": "bids", + "tableTo": "user", + "columnsFrom": [ + "bidder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "bids_amount_ok": { + "name": "bids_amount_ok", + "value": "\"bids\".\"amount\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.boss_assaults": { + "name": "boss_assaults", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "boss_assaults_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "damage": { + "name": "damage", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "boss_assaults_day_user_uq": { + "name": "boss_assaults_day_user_uq", + "columns": [ + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "boss_assaults_day_idx": { + "name": "boss_assaults_day_idx", + "columns": [ + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "boss_assaults_user_id_user_id_fk": { + "name": "boss_assaults_user_id_user_id_fk", + "tableFrom": "boss_assaults", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.boss_days": { + "name": "boss_days", + "schema": "", + "columns": { + "day": { + "name": "day", + "type": "date", + "primaryKey": true, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "season": { + "name": "season", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "max_hp": { + "name": "max_hp", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "hp": { + "name": "hp", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "phase": { + "name": "phase", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "damage": { + "name": "damage", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "weakness": { + "name": "weakness", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resistance": { + "name": "resistance", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "killed_at": { + "name": "killed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "killed_by": { + "name": "killed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "finalized_at": { + "name": "finalized_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "boss_days_killed_by_user_id_fk": { + "name": "boss_days_killed_by_user_id_fk", + "tableFrom": "boss_days", + "tableTo": "user", + "columnsFrom": [ + "killed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "boss_days_state_ok": { + "name": "boss_days_state_ok", + "value": "\"boss_days\".\"hp\" >= 0 AND \"boss_days\".\"damage\" >= 0 AND \"boss_days\".\"phase\" >= 1" + } + }, + "isRLSEnabled": false + }, + "public.boss_hits": { + "name": "boss_hits", + "schema": "", + "columns": { + "assault_id": { + "name": "assault_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "idx": { + "name": "idx", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "season": { + "name": "season", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "rarity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "atk": { + "name": "atk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "question": { + "name": "question", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "served_at": { + "name": "served_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "shown_at": { + "name": "shown_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "answered_at": { + "name": "answered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "choice": { + "name": "choice", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "correct": { + "name": "correct", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "answer_ms": { + "name": "answer_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "guess_year": { + "name": "guess_year", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mult": { + "name": "mult", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "damage": { + "name": "damage", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "boss_hits_assault_id_boss_assaults_id_fk": { + "name": "boss_hits_assault_id_boss_assaults_id_fk", + "tableFrom": "boss_hits", + "tableTo": "boss_assaults", + "columnsFrom": [ + "assault_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boss_hits_assault_id_idx_pk": { + "name": "boss_hits_assault_id_idx_pk", + "columns": [ + "assault_id", + "idx" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.boss_phases": { + "name": "boss_phases", + "schema": "", + "columns": { + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "phase": { + "name": "phase", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "max_hp": { + "name": "max_hp", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fallen_at": { + "name": "fallen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "fallen_by": { + "name": "fallen_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "boss_phases_fallen_by_user_id_fk": { + "name": "boss_phases_fallen_by_user_id_fk", + "tableFrom": "boss_phases", + "tableTo": "user", + "columnsFrom": [ + "fallen_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boss_phases_day_phase_pk": { + "name": "boss_phases_day_phase_pk", + "columns": [ + "day", + "phase" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.boss_question_history": { + "name": "boss_question_history", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "asked_at": { + "name": "asked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "boss_question_history_user_id_user_id_fk": { + "name": "boss_question_history_user_id_user_id_fk", + "tableFrom": "boss_question_history", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boss_question_history_user_id_card_id_type_key_pk": { + "name": "boss_question_history_user_id_card_id_type_key_pk", + "columns": [ + "user_id", + "card_id", + "type", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.boss_rewards": { + "name": "boss_rewards", + "schema": "", + "columns": { + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phases": { + "name": "phases", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "boss_rewards_user_id_user_id_fk": { + "name": "boss_rewards_user_id_user_id_fk", + "tableFrom": "boss_rewards", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boss_rewards_day_user_id_pk": { + "name": "boss_rewards_day_user_id_pk", + "columns": [ + "day", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.broadcast_reads": { + "name": "broadcast_reads", + "schema": "", + "columns": { + "broadcast_id": { + "name": "broadcast_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "broadcast_reads_user_idx": { + "name": "broadcast_reads_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "broadcast_reads_broadcast_id_broadcasts_id_fk": { + "name": "broadcast_reads_broadcast_id_broadcasts_id_fk", + "tableFrom": "broadcast_reads", + "tableTo": "broadcasts", + "columnsFrom": [ + "broadcast_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "broadcast_reads_user_id_user_id_fk": { + "name": "broadcast_reads_user_id_user_id_fk", + "tableFrom": "broadcast_reads", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "broadcast_reads_broadcast_id_user_id_pk": { + "name": "broadcast_reads_broadcast_id_user_id_pk", + "columns": [ + "broadcast_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.broadcasts": { + "name": "broadcasts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "broadcasts_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tone": { + "name": "tone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "link_url": { + "name": "link_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link_label": { + "name": "link_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "broadcasts_status_idx": { + "name": "broadcasts_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sent_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.card_instances": { + "name": "card_instances", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "card_instances_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "season": { + "name": "season", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "rarity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "atk": { + "name": "atk", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "def": { + "name": "def", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "shiny": { + "name": "shiny", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "favorite": { + "name": "favorite", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked_by": { + "name": "locked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pinned_slot": { + "name": "pinned_slot", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "obtained_at": { + "name": "obtained_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "card_instances_owner_card_idx": { + "name": "card_instances_owner_card_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "card_instances_card_season_idx": { + "name": "card_instances_card_season_idx", + "columns": [ + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "season", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "card_instances_owner_obtained_idx": { + "name": "card_instances_owner_obtained_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "obtained_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "card_instances_pinned_uq": { + "name": "card_instances_pinned_uq", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pinned_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"card_instances\".\"pinned_slot\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "card_instances_owner_id_user_id_fk": { + "name": "card_instances_owner_id_user_id_fk", + "tableFrom": "card_instances", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "card_instances_season_card_id_cards_season_id_fk": { + "name": "card_instances_season_card_id_cards_season_id_fk", + "tableFrom": "card_instances", + "tableTo": "cards", + "columnsFrom": [ + "season", + "card_id" + ], + "columnsTo": [ + "season", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "card_instances_level_ok": { + "name": "card_instances_level_ok", + "value": "\"card_instances\".\"level\" BETWEEN 1 AND 5" + }, + "card_instances_pinned_ok": { + "name": "card_instances_pinned_ok", + "value": "\"card_instances\".\"pinned_slot\" IS NULL OR \"card_instances\".\"pinned_slot\" BETWEEN 1 AND 5" + } + }, + "isRLSEnabled": false + }, + "public.cards": { + "name": "cards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "season": { + "name": "season", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "rarity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "atk": { + "name": "atk", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "def": { + "name": "def", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "views_12m": { + "name": "views_12m", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "page_len": { + "name": "page_len", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "rand_key": { + "name": "rand_key", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": "random()" + }, + "search_title": { + "name": "search_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "lower(f_unaccent(title))", + "type": "stored" + } + } + }, + "indexes": { + "cards_search_trgm_idx": { + "name": "cards_search_trgm_idx", + "columns": [ + { + "expression": "\"search_title\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "cards_rarity_rand_idx": { + "name": "cards_rarity_rand_idx", + "columns": [ + { + "expression": "season", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rarity", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rand_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cards_views_idx": { + "name": "cards_views_idx", + "columns": [ + { + "expression": "season", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "views_12m", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cards_hl_views_idx": { + "name": "cards_hl_views_idx", + "columns": [ + { + "expression": "season", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "views_12m", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"cards\".\"rarity\" in ('SR', 'UR', 'L')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "cards_atk_idx": { + "name": "cards_atk_idx", + "columns": [ + { + "expression": "season", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "atk", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cards_def_idx": { + "name": "cards_def_idx", + "columns": [ + { + "expression": "season", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "def", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cards_title_idx": { + "name": "cards_title_idx", + "columns": [ + { + "expression": "season", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cards_id_idx": { + "name": "cards_id_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "cards_season_id_pk": { + "name": "cards_season_id_pk", + "columns": [ + "season", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cards_next": { + "name": "cards_next", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "rarity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "atk": { + "name": "atk", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "def": { + "name": "def", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "views_12m": { + "name": "views_12m", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "page_len": { + "name": "page_len", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.community_goal_rewards": { + "name": "community_goal_rewards", + "schema": "", + "columns": { + "season": { + "name": "season", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "community_goal_rewards_user_id_user_id_fk": { + "name": "community_goal_rewards_user_id_user_id_fk", + "tableFrom": "community_goal_rewards", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "community_goal_rewards_season_tier_user_id_pk": { + "name": "community_goal_rewards_season_tier_user_id_pk", + "columns": [ + "season", + "tier", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.community_goals": { + "name": "community_goals", + "schema": "", + "columns": { + "season": { + "name": "season", + "type": "smallint", + "primaryKey": true, + "notNull": true + }, + "distinct_cards": { + "name": "distinct_cards", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "season_cards": { + "name": "season_cards", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tiers_reached": { + "name": "tiers_reached", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "computed_at": { + "name": "computed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.daily_articles": { + "name": "daily_articles", + "schema": "", + "columns": { + "day": { + "name": "day", + "type": "date", + "primaryKey": true, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "season": { + "name": "season", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "rarity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "clues": { + "name": "clues", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "attrs": { + "name": "attrs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.daily_guesses": { + "name": "daily_guesses", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "guesses": { + "name": "guesses", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "found": { + "name": "found", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "reward": { + "name": "reward", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "daily_guesses_user_id_user_id_fk": { + "name": "daily_guesses_user_id_user_id_fk", + "tableFrom": "daily_guesses", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "daily_guesses_user_id_day_pk": { + "name": "daily_guesses_user_id_day_pk", + "columns": [ + "user_id", + "day" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.friendships": { + "name": "friendships", + "schema": "", + "columns": { + "user_a": { + "name": "user_a", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_b": { + "name": "user_b", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "friendships_b_idx": { + "name": "friendships_b_idx", + "columns": [ + { + "expression": "user_b", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "friendships_user_a_user_id_fk": { + "name": "friendships_user_a_user_id_fk", + "tableFrom": "friendships", + "tableTo": "user", + "columnsFrom": [ + "user_a" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "friendships_user_b_user_id_fk": { + "name": "friendships_user_b_user_id_fk", + "tableFrom": "friendships", + "tableTo": "user", + "columnsFrom": [ + "user_b" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "friendships_requested_by_user_id_fk": { + "name": "friendships_requested_by_user_id_fk", + "tableFrom": "friendships", + "tableTo": "user", + "columnsFrom": [ + "requested_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "friendships_user_a_user_b_pk": { + "name": "friendships_user_a_user_b_pk", + "columns": [ + "user_a", + "user_b" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "friendships_order": { + "name": "friendships_order", + "value": "\"friendships\".\"user_a\" < \"friendships\".\"user_b\"" + } + }, + "isRLSEnabled": false + }, + "public.game_settings": { + "name": "game_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "game_settings_updated_by_user_id_fk": { + "name": "game_settings_updated_by_user_id_fk", + "tableFrom": "game_settings", + "tableTo": "user", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.guild_members": { + "name": "guild_members", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guild_id": { + "name": "guild_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "guild_members_guild_idx": { + "name": "guild_members_guild_idx", + "columns": [ + { + "expression": "guild_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "guild_members_user_id_user_id_fk": { + "name": "guild_members_user_id_user_id_fk", + "tableFrom": "guild_members", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "guild_members_guild_id_guilds_id_fk": { + "name": "guild_members_guild_id_guilds_id_fk", + "tableFrom": "guild_members", + "tableTo": "guilds", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.guild_objective_contributions": { + "name": "guild_objective_contributions", + "schema": "", + "columns": { + "objective_id": { + "name": "objective_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "guild_objective_contributions_objective_id_guild_objectives_id_fk": { + "name": "guild_objective_contributions_objective_id_guild_objectives_id_fk", + "tableFrom": "guild_objective_contributions", + "tableTo": "guild_objectives", + "columnsFrom": [ + "objective_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "guild_objective_contributions_user_id_user_id_fk": { + "name": "guild_objective_contributions_user_id_user_id_fk", + "tableFrom": "guild_objective_contributions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "guild_objective_contributions_objective_id_user_id_pk": { + "name": "guild_objective_contributions_objective_id_user_id_pk", + "columns": [ + "objective_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.guild_objectives": { + "name": "guild_objectives", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "guild_objectives_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "guild_id": { + "name": "guild_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "week_start": { + "name": "week_start", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "guild_objectives_week_uq": { + "name": "guild_objectives_week_uq", + "columns": [ + { + "expression": "guild_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "week_start", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "guild_objectives_guild_id_guilds_id_fk": { + "name": "guild_objectives_guild_id_guilds_id_fk", + "tableFrom": "guild_objectives", + "tableTo": "guilds", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.guilds": { + "name": "guilds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "guilds_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emblem": { + "name": "emblem", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "guilds_name_uq": { + "name": "guilds_name_uq", + "columns": [ + { + "expression": "lower(\"name\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "guilds_tag_uq": { + "name": "guilds_tag_uq", + "columns": [ + { + "expression": "lower(\"tag\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hl_runs": { + "name": "hl_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "hl_runs_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "try_no": { + "name": "try_no", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'playing'" + }, + "step": { + "name": "step", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streak": { + "name": "streak", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "season": { + "name": "season", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "kept_card_id": { + "name": "kept_card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "challenger_card_id": { + "name": "challenger_card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "deadline": { + "name": "deadline", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "end_reason": { + "name": "end_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reward": { + "name": "reward", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "practice": { + "name": "practice", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pair_renewed": { + "name": "pair_renewed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "hl_runs_try_uq": { + "name": "hl_runs_try_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "try_no", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "hl_runs_playing_idx": { + "name": "hl_runs_playing_idx", + "columns": [ + { + "expression": "deadline", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"hl_runs\".\"status\" = 'playing'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "hl_runs_month_idx": { + "name": "hl_runs_month_idx", + "columns": [ + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "streak", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hl_runs_user_id_user_id_fk": { + "name": "hl_runs_user_id_user_id_fk", + "tableFrom": "hl_runs", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hl_seen": { + "name": "hl_seen", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "hl_seen_user_id_user_id_fk": { + "name": "hl_seen_user_id_user_id_fk", + "tableFrom": "hl_seen", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hl_seen_user_id_day_card_id_pk": { + "name": "hl_seen_user_id_day_card_id_pk", + "columns": [ + "user_id", + "day", + "card_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hl_steps": { + "name": "hl_steps", + "schema": "", + "columns": { + "run_id": { + "name": "run_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "step": { + "name": "step", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "kept_card_id": { + "name": "kept_card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "challenger_card_id": { + "name": "challenger_card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "kept_views": { + "name": "kept_views", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "challenger_views": { + "name": "challenger_views", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "pick": { + "name": "pick", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correct": { + "name": "correct", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "timeout": { + "name": "timeout", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "renewed": { + "name": "renewed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "answer_ms": { + "name": "answer_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "answered_at": { + "name": "answered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hl_steps_run_id_hl_runs_id_fk": { + "name": "hl_steps_run_id_hl_runs_id_fk", + "tableFrom": "hl_steps", + "tableTo": "hl_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hl_steps_run_id_step_pk": { + "name": "hl_steps_run_id_step_pk", + "columns": [ + "run_id", + "step" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.image_filter_cards": { + "name": "image_filter_cards", + "schema": "", + "columns": { + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "image_filter_cards_card_id_theme_pk": { + "name": "image_filter_cards_card_id_theme_pk", + "columns": [ + "card_id", + "theme" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.image_filter_overrides": { + "name": "image_filter_overrides", + "schema": "", + "columns": { + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "flagged": { + "name": "flagged", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "image_filter_overrides_card_id_theme_pk": { + "name": "image_filter_overrides_card_id_theme_pk", + "columns": [ + "card_id", + "theme" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ledger": { + "name": "ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "ledger_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pw'" + }, + "delta": { + "name": "delta", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "balance_after": { + "name": "balance_after", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ledger_user_created_idx": { + "name": "ledger_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ledger_user_id_user_id_fk": { + "name": "ledger_user_id_user_id_fk", + "tableFrom": "ledger", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ledger_delta_nonzero": { + "name": "ledger_delta_nonzero", + "value": "\"ledger\".\"delta\" <> 0" + } + }, + "isRLSEnabled": false + }, + "public.message_reads": { + "name": "message_reads", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "message_reads_user_id_user_id_fk": { + "name": "message_reads_user_id_user_id_fk", + "tableFrom": "message_reads", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "message_reads_user_id_channel_pk": { + "name": "message_reads_user_id_channel_pk", + "columns": [ + "user_id", + "channel" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "messages_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "card_season": { + "name": "card_season", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "messages_channel_idx": { + "name": "messages_channel_idx", + "columns": [ + { + "expression": "channel", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_sender_id_user_id_fk": { + "name": "messages_sender_id_user_id_fk", + "tableFrom": "messages", + "tableTo": "user", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "messages_body_len": { + "name": "messages_body_len", + "value": "char_length(\"messages\".\"body\") BETWEEN 0 AND 1000" + } + }, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "notifications_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_user_idx": { + "name": "notifications_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_user_id_user_id_fk": { + "name": "notifications_user_id_user_id_fk", + "tableFrom": "notifications", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pack_stats": { + "name": "pack_stats", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "season": { + "name": "season", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "packs": { + "name": "packs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "luck_packs": { + "name": "luck_packs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "pulled_points": { + "name": "pulled_points", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expected_points": { + "name": "expected_points", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "pack_stats_user_id_user_id_fk": { + "name": "pack_stats_user_id_user_id_fk", + "tableFrom": "pack_stats", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pack_stats_user_id_season_pk": { + "name": "pack_stats_user_id_season_pk", + "columns": [ + "user_id", + "season" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.player_avatars": { + "name": "player_avatars", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "image": { + "name": "image", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "mime": { + "name": "mime", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "player_avatars_user_id_user_id_fk": { + "name": "player_avatars_user_id_user_id_fk", + "tableFrom": "player_avatars", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "player_avatars_size_ok": { + "name": "player_avatars_size_ok", + "value": "octet_length(\"player_avatars\".\"image\") <= 150000" + } + }, + "isRLSEnabled": false + }, + "public.player_banners": { + "name": "player_banners", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "image": { + "name": "image", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "mime": { + "name": "mime", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "player_banners_user_id_user_id_fk": { + "name": "player_banners_user_id_user_id_fk", + "tableFrom": "player_banners", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "player_banners_size_ok": { + "name": "player_banners_size_ok", + "value": "octet_length(\"player_banners\".\"image\") <= 180000" + } + }, + "isRLSEnabled": false + }, + "public.player_portfolio_designs": { + "name": "player_portfolio_designs", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "design_key": { + "name": "design_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "acquired_at": { + "name": "acquired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "player_portfolio_designs_user_id_user_id_fk": { + "name": "player_portfolio_designs_user_id_user_id_fk", + "tableFrom": "player_portfolio_designs", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "player_portfolio_designs_user_id_design_key_pk": { + "name": "player_portfolio_designs_user_id_design_key_pk", + "columns": [ + "user_id", + "design_key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.player_quests": { + "name": "player_quests", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "slot": { + "name": "slot", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reward_pw": { + "name": "reward_pw", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reward_xp": { + "name": "reward_xp", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "rerolled": { + "name": "rerolled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "player_quests_user_id_user_id_fk": { + "name": "player_quests_user_id_user_id_fk", + "tableFrom": "player_quests", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "player_quests_user_id_period_period_start_slot_pk": { + "name": "player_quests_user_id_period_period_start_slot_pk", + "columns": [ + "user_id", + "period", + "period_start", + "slot" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "player_quests_target_ok": { + "name": "player_quests_target_ok", + "value": "\"player_quests\".\"target\" > 0 AND \"player_quests\".\"progress\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.player_stats": { + "name": "player_stats", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "player_stats_user_id_user_id_fk": { + "name": "player_stats_user_id_user_id_fk", + "tableFrom": "player_stats", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "player_stats_user_id_key_pk": { + "name": "player_stats_user_id_key_pk", + "columns": [ + "user_id", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.player_theme_packs": { + "name": "player_theme_packs", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme_id": { + "name": "theme_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "opened": { + "name": "opened", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "player_theme_packs_theme_idx": { + "name": "player_theme_packs_theme_idx", + "columns": [ + { + "expression": "theme_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "player_theme_packs_user_id_user_id_fk": { + "name": "player_theme_packs_user_id_user_id_fk", + "tableFrom": "player_theme_packs", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "player_theme_packs_theme_id_themes_id_fk": { + "name": "player_theme_packs_theme_id_themes_id_fk", + "tableFrom": "player_theme_packs", + "tableTo": "themes", + "columnsFrom": [ + "theme_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "player_theme_packs_user_id_theme_id_pk": { + "name": "player_theme_packs_user_id_theme_id_pk", + "columns": [ + "user_id", + "theme_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "player_theme_packs_count_ok": { + "name": "player_theme_packs_count_ok", + "value": "\"player_theme_packs\".\"count\" >= 0" + }, + "player_theme_packs_opened_ok": { + "name": "player_theme_packs_opened_ok", + "value": "\"player_theme_packs\".\"opened\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.player_titles": { + "name": "player_titles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "season": { + "name": "season", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "board": { + "name": "board", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rank": { + "name": "rank", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "awarded_at": { + "name": "awarded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "player_titles_user_id_user_id_fk": { + "name": "player_titles_user_id_user_id_fk", + "tableFrom": "player_titles", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "player_titles_user_id_season_board_pk": { + "name": "player_titles_user_id_season_board_pk", + "columns": [ + "user_id", + "season", + "board" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.players": { + "name": "players", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "locked_balance": { + "name": "locked_balance", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "packs_stored": { + "name": "packs_stored", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "packs_updated_at": { + "name": "packs_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "bonus_packs": { + "name": "bonus_packs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "packs_full_notified": { + "name": "packs_full_notified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pity_counter": { + "name": "pity_counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "elo": { + "name": "elo", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1000 + }, + "elo_peak": { + "name": "elo_peak", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1000 + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "animation_speed": { + "name": "animation_speed", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'normal'" + }, + "auto_recycle_max": { + "name": "auto_recycle_max", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_recycle_keep_new": { + "name": "auto_recycle_keep_new", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "hide_arthropods": { + "name": "hide_arthropods", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hide_gore": { + "name": "hide_gore", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hide_nudity": { + "name": "hide_nudity", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "quick_upgrade": { + "name": "quick_upgrade", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "public_tags": { + "name": "public_tags", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notification_prefs": { + "name": "notification_prefs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "login_streak": { + "name": "login_streak", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_login_day": { + "name": "last_login_day", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "last_wheel_day": { + "name": "last_wheel_day", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "wheel_step": { + "name": "wheel_step", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "wheel_last_at": { + "name": "wheel_last_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "season_xp": { + "name": "season_xp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "pass_season": { + "name": "pass_season", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "pass_rewarded": { + "name": "pass_rewarded", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stats_version": { + "name": "stats_version", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_quest_reroll_day": { + "name": "last_quest_reroll_day", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "seen_features": { + "name": "seen_features", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "suggestion_banner_until": { + "name": "suggestion_banner_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "title_season": { + "name": "title_season", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "title_board": { + "name": "title_board", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_note": { + "name": "status_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_note_at": { + "name": "status_note_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "banner_card_id": { + "name": "banner_card_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "players_user_id_user_id_fk": { + "name": "players_user_id_user_id_fk", + "tableFrom": "players", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "players_balance_ok": { + "name": "players_balance_ok", + "value": "\"players\".\"balance\" >= \"players\".\"locked_balance\" AND \"players\".\"locked_balance\" >= 0" + }, + "players_packs_ok": { + "name": "players_packs_ok", + "value": "\"players\".\"packs_stored\" >= 0 AND \"players\".\"bonus_packs\" >= 0 AND \"players\".\"pity_counter\" >= 0" + }, + "players_xp_ok": { + "name": "players_xp_ok", + "value": "\"players\".\"season_xp\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.portfolio_backgrounds": { + "name": "portfolio_backgrounds", + "schema": "", + "columns": { + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "image": { + "name": "image", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "mime": { + "name": "mime", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "portfolio_backgrounds_portfolio_id_portfolios_id_fk": { + "name": "portfolio_backgrounds_portfolio_id_portfolios_id_fk", + "tableFrom": "portfolio_backgrounds", + "tableTo": "portfolios", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "portfolio_backgrounds_size_ok": { + "name": "portfolio_backgrounds_size_ok", + "value": "octet_length(\"portfolio_backgrounds\".\"image\") <= 180000" + } + }, + "isRLSEnabled": false + }, + "public.portfolio_cards": { + "name": "portfolio_cards", + "schema": "", + "columns": { + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "pos": { + "name": "pos", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "featured": { + "name": "featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "portfolio_cards_instance_idx": { + "name": "portfolio_cards_instance_idx", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "portfolio_cards_portfolio_id_portfolios_id_fk": { + "name": "portfolio_cards_portfolio_id_portfolios_id_fk", + "tableFrom": "portfolio_cards", + "tableTo": "portfolios", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "portfolio_cards_instance_id_card_instances_id_fk": { + "name": "portfolio_cards_instance_id_card_instances_id_fk", + "tableFrom": "portfolio_cards", + "tableTo": "card_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "portfolio_cards_portfolio_id_instance_id_pk": { + "name": "portfolio_cards_portfolio_id_instance_id_pk", + "columns": [ + "portfolio_id", + "instance_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "portfolio_cards_pos_ok": { + "name": "portfolio_cards_pos_ok", + "value": "\"portfolio_cards\".\"pos\" BETWEEN 0 AND 29" + } + }, + "isRLSEnabled": false + }, + "public.portfolio_likes": { + "name": "portfolio_likes", + "schema": "", + "columns": { + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "portfolio_likes_user_idx": { + "name": "portfolio_likes_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "portfolio_likes_portfolio_id_portfolios_id_fk": { + "name": "portfolio_likes_portfolio_id_portfolios_id_fk", + "tableFrom": "portfolio_likes", + "tableTo": "portfolios", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "portfolio_likes_user_id_user_id_fk": { + "name": "portfolio_likes_user_id_user_id_fk", + "tableFrom": "portfolio_likes", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "portfolio_likes_portfolio_id_user_id_pk": { + "name": "portfolio_likes_portfolio_id_user_id_pk", + "columns": [ + "portfolio_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.portfolio_members": { + "name": "portfolio_members", + "schema": "", + "columns": { + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_at": { + "name": "invited_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "portfolio_members_user_idx": { + "name": "portfolio_members_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "portfolio_members_portfolio_id_portfolios_id_fk": { + "name": "portfolio_members_portfolio_id_portfolios_id_fk", + "tableFrom": "portfolio_members", + "tableTo": "portfolios", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "portfolio_members_user_id_user_id_fk": { + "name": "portfolio_members_user_id_user_id_fk", + "tableFrom": "portfolio_members", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "portfolio_members_portfolio_id_user_id_pk": { + "name": "portfolio_members_portfolio_id_user_id_pk", + "columns": [ + "portfolio_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.portfolios": { + "name": "portfolios", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "portfolios_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "background": { + "name": "background", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'papier'" + }, + "frame": { + "name": "frame", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'filet'" + }, + "accent": { + "name": "accent", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'or'" + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "profile_slot": { + "name": "profile_slot", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "portfolios_user_idx": { + "name": "portfolios_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "portfolios_created_idx": { + "name": "portfolios_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "portfolios_profile_uq": { + "name": "portfolios_profile_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"portfolios\".\"profile_slot\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "portfolios_user_id_user_id_fk": { + "name": "portfolios_user_id_user_id_fk", + "tableFrom": "portfolios", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "portfolios_name_len": { + "name": "portfolios_name_len", + "value": "char_length(\"portfolios\".\"name\") BETWEEN 1 AND 40" + }, + "portfolios_description_len": { + "name": "portfolios_description_len", + "value": "char_length(\"portfolios\".\"description\") <= 280" + }, + "portfolios_profile_ok": { + "name": "portfolios_profile_ok", + "value": "\"portfolios\".\"profile_slot\" IS NULL OR \"portfolios\".\"profile_slot\" BETWEEN 1 AND 3" + } + }, + "isRLSEnabled": false + }, + "public.promo_codes": { + "name": "promo_codes", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "pw": { + "name": "pw", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "packs": { + "name": "packs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "theme_id": { + "name": "theme_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "theme_packs": { + "name": "theme_packs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "uses": { + "name": "uses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "promo_codes_theme_id_themes_id_fk": { + "name": "promo_codes_theme_id_themes_id_fk", + "tableFrom": "promo_codes", + "tableTo": "themes", + "columnsFrom": [ + "theme_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "promo_codes_amounts_ok": { + "name": "promo_codes_amounts_ok", + "value": "\"promo_codes\".\"pw\" >= 0 AND \"promo_codes\".\"packs\" >= 0 AND \"promo_codes\".\"theme_packs\" >= 0" + }, + "promo_codes_uses_ok": { + "name": "promo_codes_uses_ok", + "value": "\"promo_codes\".\"uses\" >= 0 AND (\"promo_codes\".\"max_uses\" IS NULL OR \"promo_codes\".\"uses\" <= \"promo_codes\".\"max_uses\")" + }, + "promo_codes_code_ok": { + "name": "promo_codes_code_ok", + "value": "\"promo_codes\".\"code\" ~ '^[A-Z0-9_-]{3,32}$'" + } + }, + "isRLSEnabled": false + }, + "public.promo_redemptions": { + "name": "promo_redemptions", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "promo_redemptions_user_idx": { + "name": "promo_redemptions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "promo_redemptions_code_promo_codes_code_fk": { + "name": "promo_redemptions_code_promo_codes_code_fk", + "tableFrom": "promo_redemptions", + "tableTo": "promo_codes", + "columnsFrom": [ + "code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "promo_redemptions_user_id_user_id_fk": { + "name": "promo_redemptions_user_id_user_id_fk", + "tableFrom": "promo_redemptions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "promo_redemptions_code_user_id_pk": { + "name": "promo_redemptions_code_user_id_pk", + "columns": [ + "code", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pull_reactions": { + "name": "pull_reactions", + "schema": "", + "columns": { + "pull_id": { + "name": "pull_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pull_reactions_pull_id_pulls_id_fk": { + "name": "pull_reactions_pull_id_pulls_id_fk", + "tableFrom": "pull_reactions", + "tableTo": "pulls", + "columnsFrom": [ + "pull_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pull_reactions_user_id_user_id_fk": { + "name": "pull_reactions_user_id_user_id_fk", + "tableFrom": "pull_reactions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pull_reactions_pull_id_user_id_emoji_pk": { + "name": "pull_reactions_pull_id_user_id_emoji_pk", + "columns": [ + "pull_id", + "user_id", + "emoji" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pulls": { + "name": "pulls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "pulls_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "season": { + "name": "season", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "rarity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "shiny": { + "name": "shiny", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "genres": { + "name": "genres", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pulls_created_idx": { + "name": "pulls_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pulls_card_idx": { + "name": "pulls_card_idx", + "columns": [ + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pulls_season_card_idx": { + "name": "pulls_season_card_idx", + "columns": [ + { + "expression": "season", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pulls_user_idx": { + "name": "pulls_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pulls_user_id_user_id_fk": { + "name": "pulls_user_id_user_id_fk", + "tableFrom": "pulls", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sales": { + "name": "sales", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "sales_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "rarity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "auction_id": { + "name": "auction_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "sold_at": { + "name": "sold_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sales_card_sold_idx": { + "name": "sales_card_sold_idx", + "columns": [ + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sold_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sales_auction_id_auctions_id_fk": { + "name": "sales_auction_id_auctions_id_fk", + "tableFrom": "sales", + "tableTo": "auctions", + "columnsFrom": [ + "auction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_decks": { + "name": "saved_decks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "saved_decks_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cards": { + "name": "cards", + "type": "bigint[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "saved_decks_user_idx": { + "name": "saved_decks_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_decks_user_id_user_id_fk": { + "name": "saved_decks_user_id_user_id_fk", + "tableFrom": "saved_decks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.season_archives": { + "name": "season_archives", + "schema": "", + "columns": { + "season": { + "name": "season", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "collection_score": { + "name": "collection_score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "elo": { + "name": "elo", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "wealth": { + "name": "wealth", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "guild_id": { + "name": "guild_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "season_archives_user_id_user_id_fk": { + "name": "season_archives_user_id_user_id_fk", + "tableFrom": "season_archives", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "season_archives_season_user_id_pk": { + "name": "season_archives_season_user_id_pk", + "columns": [ + "season", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seasons": { + "name": "seasons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "smallint", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "seasons_one_active": { + "name": "seasons_one_active", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"seasons\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shop_offers": { + "name": "shop_offers", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "slot": { + "name": "slot", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "season": { + "name": "season", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "rarity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "purchased_at": { + "name": "purchased_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "instance_id": { + "name": "instance_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shop_offers_card_uq": { + "name": "shop_offers_card_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shop_offers_user_id_user_id_fk": { + "name": "shop_offers_user_id_user_id_fk", + "tableFrom": "shop_offers", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shop_offers_user_id_day_slot_pk": { + "name": "shop_offers_user_id_day_slot_pk", + "columns": [ + "user_id", + "day", + "slot" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shop_offers_price_ok": { + "name": "shop_offers_price_ok", + "value": "\"shop_offers\".\"price\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.suggestion_automation": { + "name": "suggestion_automation", + "schema": "", + "columns": { + "suggestion_id": { + "name": "suggestion_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "triage_status": { + "name": "triage_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verdict": { + "name": "verdict", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoning": { + "name": "reasoning", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "spec": { + "name": "spec", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "questions": { + "name": "questions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "proposed_reply": { + "name": "proposed_reply", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duplicate_of": { + "name": "duplicate_of", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "injection": { + "name": "injection", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "triaged_at": { + "name": "triaged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "build_status": { + "name": "build_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "issue_number": { + "name": "issue_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ci_conclusion": { + "name": "ci_conclusion", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "player_reply": { + "name": "player_reply", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "announcement": { + "name": "announcement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "suggestion_automation_triage_idx": { + "name": "suggestion_automation_triage_idx", + "columns": [ + { + "expression": "triage_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "suggestion_automation_build_idx": { + "name": "suggestion_automation_build_idx", + "columns": [ + { + "expression": "build_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "suggestion_automation_suggestion_id_suggestions_id_fk": { + "name": "suggestion_automation_suggestion_id_suggestions_id_fk", + "tableFrom": "suggestion_automation", + "tableTo": "suggestions", + "columnsFrom": [ + "suggestion_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.suggestion_messages": { + "name": "suggestion_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "suggestion_messages_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "suggestion_id": { + "name": "suggestion_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "from_admin": { + "name": "from_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "admin_seen_at": { + "name": "admin_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "suggestion_messages_suggestion_idx": { + "name": "suggestion_messages_suggestion_idx", + "columns": [ + { + "expression": "suggestion_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "suggestion_messages_suggestion_id_suggestions_id_fk": { + "name": "suggestion_messages_suggestion_id_suggestions_id_fk", + "tableFrom": "suggestion_messages", + "tableTo": "suggestions", + "columnsFrom": [ + "suggestion_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.suggestions": { + "name": "suggestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "suggestions_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "reply": { + "name": "reply", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "replied_at": { + "name": "replied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "admin_seen_at": { + "name": "admin_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "suggestions_user_idx": { + "name": "suggestions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "suggestions_status_idx": { + "name": "suggestions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "suggestions_user_id_user_id_fk": { + "name": "suggestions_user_id_user_id_fk", + "tableFrom": "suggestions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.theme_cards": { + "name": "theme_cards", + "schema": "", + "columns": { + "theme_id": { + "name": "theme_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "rarity", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "pos": { + "name": "pos", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "theme_cards_card_idx": { + "name": "theme_cards_card_idx", + "columns": [ + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "theme_cards_draw_idx": { + "name": "theme_cards_draw_idx", + "columns": [ + { + "expression": "theme_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rarity", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pos", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "theme_cards_theme_id_themes_id_fk": { + "name": "theme_cards_theme_id_themes_id_fk", + "tableFrom": "theme_cards", + "tableTo": "themes", + "columnsFrom": [ + "theme_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "theme_cards_theme_id_card_id_pk": { + "name": "theme_cards_theme_id_card_id_pk", + "columns": [ + "theme_id", + "card_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.themes": { + "name": "themes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "themes_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "card_count": { + "name": "card_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "indexed_season": { + "name": "indexed_season", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "by_rarity": { + "name": "by_rarity", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "preview_ids": { + "name": "preview_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "themes_ends_idx": { + "name": "themes_ends_idx", + "columns": [ + { + "expression": "ends_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "themes_price_ok": { + "name": "themes_price_ok", + "value": "\"themes\".\"price\" > 0" + }, + "themes_dates_ok": { + "name": "themes_dates_ok", + "value": "\"themes\".\"ends_at\" > \"themes\".\"starts_at\"" + } + }, + "isRLSEnabled": false + }, + "public.trade_items": { + "name": "trade_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "trade_items_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "trade_id": { + "name": "trade_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "season": { + "name": "season", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "rarity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "shiny": { + "name": "shiny", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "atk": { + "name": "atk", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "def": { + "name": "def", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "fate": { + "name": "fate", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fate_at": { + "name": "fate_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "trade_items_trade_instance_uq": { + "name": "trade_items_trade_instance_uq", + "columns": [ + { + "expression": "trade_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trade_items_instance_idx": { + "name": "trade_items_instance_idx", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "trade_items_trade_id_trades_id_fk": { + "name": "trade_items_trade_id_trades_id_fk", + "tableFrom": "trade_items", + "tableTo": "trades", + "columnsFrom": [ + "trade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "trade_items_instance_id_card_instances_id_fk": { + "name": "trade_items_instance_id_card_instances_id_fk", + "tableFrom": "trade_items", + "tableTo": "card_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trades": { + "name": "trades", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "trades_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "from_id": { + "name": "from_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_id": { + "name": "to_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_pw": { + "name": "from_pw", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "to_pw": { + "name": "to_pw", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "trades_to_idx": { + "name": "trades_to_idx", + "columns": [ + { + "expression": "to_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trades_from_idx": { + "name": "trades_from_idx", + "columns": [ + { + "expression": "from_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "trades_from_id_user_id_fk": { + "name": "trades_from_id_user_id_fk", + "tableFrom": "trades", + "tableTo": "user", + "columnsFrom": [ + "from_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "trades_to_id_user_id_fk": { + "name": "trades_to_id_user_id_fk", + "tableFrom": "trades", + "tableTo": "user", + "columnsFrom": [ + "to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "trades_pw_ok": { + "name": "trades_pw_ok", + "value": "\"trades\".\"from_pw\" >= 0 AND \"trades\".\"to_pw\" >= 0" + }, + "trades_distinct": { + "name": "trades_distinct", + "value": "\"trades\".\"from_id\" <> \"trades\".\"to_id\"" + } + }, + "isRLSEnabled": false + }, + "public.upgrade_series_runs": { + "name": "upgrade_series_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "upgrade_series_runs_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "rarity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "rarity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "lots": { + "name": "lots", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "successes": { + "name": "successes", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "refund": { + "name": "refund", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "upgrade_series_runs_user_idx": { + "name": "upgrade_series_runs_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "upgrade_series_runs_user_id_user_id_fk": { + "name": "upgrade_series_runs_user_id_user_id_fk", + "tableFrom": "upgrade_series_runs", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_tags": { + "name": "user_tags", + "schema": "", + "columns": { + "instance_id": { + "name": "instance_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_tags_instance_id_card_instances_id_fk": { + "name": "user_tags_instance_id_card_instances_id_fk", + "tableFrom": "user_tags", + "tableTo": "card_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_tags_instance_id_tag_pk": { + "name": "user_tags_instance_id_tag_pk", + "columns": [ + "instance_id", + "tag" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_tags_len": { + "name": "user_tags_len", + "value": "char_length(\"user_tags\".\"tag\") BETWEEN 1 AND 24" + } + }, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.weird_cards": { + "name": "weird_cards", + "schema": "", + "columns": { + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "genre": { + "name": "genre", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "weird_cards_card_id_genre_pk": { + "name": "weird_cards_card_id_genre_pk", + "columns": [ + "card_id", + "genre" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wiki_attributes": { + "name": "wiki_attributes", + "schema": "", + "columns": { + "page_id": { + "name": "page_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "qid": { + "name": "qid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "human": { + "name": "human", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "country_id": { + "name": "country_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "continents": { + "name": "continents", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "year": { + "name": "year", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "year_kind": { + "name": "year_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "year_in_article": { + "name": "year_in_article", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wiki_summaries": { + "name": "wiki_summaries", + "schema": "", + "columns": { + "page_id": { + "name": "page_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "extract": { + "name": "extract", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumb_url": { + "name": "thumb_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "page_url": { + "name": "page_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "arthropod": { + "name": "arthropod", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wishlist": { + "name": "wishlist", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wishlist_card_idx": { + "name": "wishlist_card_idx", + "columns": [ + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wishlist_user_id_user_id_fk": { + "name": "wishlist_user_id_user_id_fk", + "tableFrom": "wishlist", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "wishlist_user_id_card_id_pk": { + "name": "wishlist_user_id_card_id_pk", + "columns": [ + "user_id", + "card_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.rarity": { + "name": "rarity", + "schema": "public", + "values": [ + "C", + "PC", + "R", + "SR", + "UR", + "L" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index a486ddd..31e3945 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -365,6 +365,13 @@ "when": 1793286523199, "tag": "0051_wiki_year_in_article", "breakpoints": true + }, + { + "idx": 52, + "version": "7", + "when": 1793317965840, + "tag": "0052_portfolio_members", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index e880ad5..e3c8cc4 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1502,6 +1502,10 @@ export const portfolios = pgTable( background: text("background").notNull().default("papier"), frame: text("frame").notNull().default("filet"), accent: text("accent").notNull().default("or"), + /** `members` : visible de ses seuls participants (portfolio partagé, suggestion n° 125). */ + visibility: text("visibility", { enum: ["public", "members"] }) + .notNull() + .default("public"), profileSlot: smallint("profile_slot"), createdAt: tstz("created_at").notNull().defaultNow(), updatedAt: tstz("updated_at").notNull().defaultNow(), @@ -1541,6 +1545,23 @@ export const portfolioCards = pgTable( ], ); +/** + * Ami invité dans un portfolio partagé (suggestion n° 125) : invitation en attente tant que `joinedAt` est nul. + * Un participant y pose ses propres cartes ; elles en sortent s'il part ou si le propriétaire le retire. + */ +export const portfolioMembers = pgTable( + "portfolio_members", + { + portfolioId: bigint("portfolio_id", { mode: "number" }) + .notNull() + .references(() => portfolios.id, { onDelete: "cascade" }), + userId: userRef("user_id").notNull(), + invitedAt: tstz("invited_at").notNull().defaultNow(), + joinedAt: tstz("joined_at"), + }, + (t) => [primaryKey({ columns: [t.portfolioId, t.userId] }), index("portfolio_members_user_idx").on(t.userId)], +); + /** Image envoyée par le joueur comme fond d'un portfolio (mêmes contrôles que les bannières). */ export const portfolioBackgrounds = pgTable( "portfolio_backgrounds", diff --git a/packages/game/src/portfolios.test.ts b/packages/game/src/portfolios.test.ts index 8dac485..a2c982d 100644 --- a/packages/game/src/portfolios.test.ts +++ b/packages/game/src/portfolios.test.ts @@ -6,6 +6,7 @@ import { PORTFOLIO_CARDS_MAX, PORTFOLIO_DESIGNS, PORTFOLIO_PRICES, + portfolioCardsChange, } from "./portfolios.js"; describe("designs des portfolios", () => { @@ -61,3 +62,30 @@ describe("cartes d'un portfolio", () => { ).toBeNull(); }); }); + +describe("portfolios partagés (suggestion n° 125)", () => { + const before = [ + { instanceId: 1, ownerId: "owner" }, + { instanceId: 2, ownerId: "ami" }, + { instanceId: 3, ownerId: "autre" }, + ]; + + it("laisse un invité ajouter, réordonner et retirer ses propres cartes", () => { + expect(portfolioCardsChange({ editorId: "ami", ownerId: "owner", before, after: [3, 1, 9] })).toEqual({ + added: [9], + removed: [2], + }); + }); + + it("refuse qu'un invité retire la carte d'un autre", () => { + expect(portfolioCardsChange({ editorId: "ami", ownerId: "owner", before, after: [2, 3] })).toBeNull(); + expect(portfolioCardsChange({ editorId: "ami", ownerId: "owner", before, after: [1, 2] })).toBeNull(); + }); + + it("laisse le propriétaire retirer n'importe quelle carte", () => { + expect(portfolioCardsChange({ editorId: "owner", ownerId: "owner", before, after: [] })).toEqual({ + added: [], + removed: [1, 2, 3], + }); + }); +}); diff --git a/packages/game/src/portfolios.ts b/packages/game/src/portfolios.ts index 4f8d21c..831f0cd 100644 --- a/packages/game/src/portfolios.ts +++ b/packages/game/src/portfolios.ts @@ -168,3 +168,33 @@ export function normalizePortfolioCards( return { instanceId: c.instanceId, featured: on }; }); } + +// --------------------------------------------------------------------------- +// Portfolios partagés (suggestion n° 125) : le propriétaire invite des amis, qui y ajoutent leurs propres cartes. +// Aucune carte ne change de main : chacune reste à celui qui l'a posée et sort du portfolio s'il le quitte. +// --------------------------------------------------------------------------- + +/** Joueurs au plus dans un portfolio partagé, propriétaire et invitations en attente compris. */ +export const PORTFOLIO_SHARED_MAX_PLAYERS = 5; + +/** Qui voit le portfolio : tout le monde (page Communauté, profil du propriétaire) ou ses participants seuls. */ +export const PORTFOLIO_VISIBILITIES = ["public", "members"] as const; +export type PortfolioVisibility = (typeof PORTFOLIO_VISIBILITIES)[number]; + +/** + * Nouvel ordre demandé par un participant : cartes ajoutées (à vérifier dans sa collection) et cartes retirées. + * Un invité ne retire que ses propres cartes ; le propriétaire peut retirer celles de tout le monde (c'est sa page). + * Renvoie null si un invité retire une carte qui n'est pas à lui. + */ +export function portfolioCardsChange(input: { + editorId: string; + ownerId: string; + before: { instanceId: number; ownerId: string }[]; + after: number[]; +}): { added: number[]; removed: number[] } | null { + const next = new Set(input.after); + const known = new Set(input.before.map((c) => c.instanceId)); + const removed = input.before.filter((c) => !next.has(c.instanceId)); + if (input.editorId !== input.ownerId && removed.some((c) => c.ownerId !== input.editorId)) return null; + return { added: input.after.filter((id) => !known.has(id)), removed: removed.map((c) => c.instanceId) }; +} diff --git a/packages/shared/src/dto.ts b/packages/shared/src/dto.ts index ecfd19d..fc19acf 100644 --- a/packages/shared/src/dto.ts +++ b/packages/shared/src/dto.ts @@ -853,15 +853,24 @@ export interface PortfolioSummaryDTO { profileSlot: number | null; /** Carte en vedette d'abord, puis les suivantes dans l'ordre (6 au plus). */ preview: PortfolioCardDTO[]; + /** Portfolio partagé (suggestion n° 125) : `members` le cache à tous sauf ses participants. */ + visibility: "public" | "members"; + /** Place du visiteur : propriétaire, ami qui y participe, ami invité (pas encore répondu), ou simple visiteur. */ + role: "owner" | "member" | "invited" | null; + /** Amis qui y participent (invitation acceptée), dans l'ordre d'arrivée. */ + members: PortfolioOwnerDTO[]; createdAt: string; updatedAt: string; } -export type PortfolioCardDTO = CardDTO & { featured: boolean }; +/** `by` : pseudo du joueur à qui est la carte (celui qui l'a posée). */ +export type PortfolioCardDTO = CardDTO & { featured: boolean; by: string }; /** Portfolio complet (page publique, éditeur). */ export interface PortfolioDTO extends PortfolioSummaryDTO { cards: PortfolioCardDTO[]; + /** Invitations en attente (le propriétaire seul les voit ; liste vide pour les autres). */ + invited: PortfolioOwnerDTO[]; } /** Design du catalogue, avec ce qu'il faut pour l'obtenir et s'il est déjà au joueur. */ From 0c555373adbc53a47da255e0cde895769d24ff13 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898283+github-actions[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:01:31 +0000 Subject: [PATCH 2/4] feat: invitations et partage des portfolios dans l'interface --- .../(game)/portfolios/[id]/modifier/page.tsx | 330 ++++++++++++++++-- .../src/app/(game)/portfolios/[id]/page.tsx | 19 +- apps/web/src/app/(game)/portfolios/page.tsx | 59 +++- apps/web/src/app/(game)/settings/page.tsx | 2 +- apps/web/src/components/Portfolio.tsx | 113 +++++- apps/web/src/lib/notifications.ts | 10 + 6 files changed, 480 insertions(+), 53 deletions(-) diff --git a/apps/web/src/app/(game)/portfolios/[id]/modifier/page.tsx b/apps/web/src/app/(game)/portfolios/[id]/modifier/page.tsx index 6573226..7a7a375 100644 --- a/apps/web/src/app/(game)/portfolios/[id]/modifier/page.tsx +++ b/apps/web/src/app/(game)/portfolios/[id]/modifier/page.tsx @@ -1,8 +1,33 @@ "use client"; -import { PORTFOLIO_ACCENTS, PORTFOLIO_CARDS_MAX, PORTFOLIO_DESCRIPTION_MAX, PORTFOLIO_NAME_MAX } from "@palacards/game"; -import type { CardDTO, Page, PortfolioCardDTO, PortfolioDesignItemDTO, PortfolioDTO } from "@palacards/shared"; -import { ChevronLeft, ChevronRight, GripVertical, ImageUp, Lock, Plus, Star, Trash2, X } from "lucide-react"; +import { + PORTFOLIO_ACCENTS, + PORTFOLIO_CARDS_MAX, + PORTFOLIO_DESCRIPTION_MAX, + PORTFOLIO_NAME_MAX, + PORTFOLIO_SHARED_MAX_PLAYERS, +} from "@palacards/game"; +import type { + CardDTO, + Page, + PortfolioCardDTO, + PortfolioDesignItemDTO, + PortfolioDTO, + PortfolioOwnerDTO, +} from "@palacards/shared"; +import { + ChevronLeft, + ChevronRight, + GripVertical, + ImageUp, + Lock, + LogOut, + Plus, + Star, + Trash2, + UserPlus, + X, +} from "lucide-react"; import { motion } from "motion/react"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -16,7 +41,8 @@ import { useCollectionFilters, type CollectionFilterOptions, } from "@/components/CollectionFilters"; -import { portfolioBackgroundSrc, PortfolioShell } from "@/components/Portfolio"; +import { Avatar } from "@/components/Avatar"; +import { participates, portfolioBackgroundSrc, PortfolioShell } from "@/components/Portfolio"; import { CardSkeletons, ConfirmDialog, Empty, ErrorBox, LoadMore } from "@/components/ui"; import { api, ApiError } from "@/lib/api"; import { preparePortfolioBackground } from "@/lib/avatar-image"; @@ -30,8 +56,12 @@ const errorText = (err: unknown, fallback: string) => (err instanceof ApiError ? // Cartes : ordre (poignée, flèches), vedette, retrait, ajout // --------------------------------------------------------------------------- -function CardsEditor({ p, mutate }: { p: PortfolioDTO; mutate: KeyedMutator }) { +function CardsEditor({ p, me, mutate }: { p: PortfolioDTO; me: string; mutate: KeyedMutator }) { const [order, setOrder] = useState(p.cards); + // Portfolio partagé : chacun retire ses cartes, le propriétaire celles de tous. + const shared = p.members.length > 0; + const canRemove = (c: PortfolioCardDTO) => p.mine || c.by === me; + const names = new Map([p.owner, ...p.members].map((m) => [m.username, m.displayName])); const [synced, setSynced] = useState(p.cards); const [picking, setPicking] = useState(false); const [announce, setAnnounce] = useState(""); @@ -108,7 +138,7 @@ function CardsEditor({ p, mutate }: { p: PortfolioDTO; mutate: KeyedMutator !known.has(c.instanceId)) .slice(0, PORTFOLIO_CARDS_MAX - order.length) - .map((c) => ({ ...c, featured: false })); + .map((c) => ({ ...c, featured: false, by: me })); if (!fresh.length) return; setInstant(false); void save([...order, ...fresh], order); @@ -126,7 +156,10 @@ function CardsEditor({ p, mutate }: { p: PortfolioDTO; mutate: KeyedMutator -

Poignée ou flèches pour l’ordre, étoile pour la vedette.

+

+ Poignée ou flèches pour l’ordre, étoile pour la vedette. + {shared && !p.mine && " Tu ne retires que tes cartes."} +

    {order.map((card, i) => { @@ -202,14 +235,24 @@ function CardsEditor({ p, mutate }: { p: PortfolioDTO; mutate: KeyedMutator - + {shared && card.by !== me && ( + + {names.get(card.by) ?? card.by} + + )} + {canRemove(card) && ( + + )} ); @@ -681,6 +724,227 @@ function DesignPanel({ p, mutate }: { p: PortfolioDTO; mutate: KeyedMutator }) { + const friends = useSWR<{ friends: Friend[] }>("/friends"); + const [picked, setPicked] = useState(""); + const [busy, setBusy] = useState(false); + const [removing, setRemoving] = useState(null); + const taken = new Set([...p.members, ...p.invited].map((m) => m.username)); + const candidates = (friends.data?.friends ?? []).filter((f) => !taken.has(f.username)); + const players = 1 + p.members.length + p.invited.length; + const full = players >= PORTFOLIO_SHARED_MAX_PLAYERS; + const target = picked || candidates[0]?.username || ""; + + async function run(fn: () => Promise, done?: string) { + setBusy(true); + try { + await fn(); + await mutate(); + if (done) toast.success(done); + } catch (err) { + toast.error(errorText(err, "Modification non enregistrée.")); + } finally { + setBusy(false); + } + } + + function invite(e: FormEvent) { + e.preventDefault(); + const friend = candidates.find((f) => f.username === target); + if (!friend) return; + setPicked(""); + void run( + () => api(`/portfolios/${p.id}/members`, { body: { username: friend.username } }), + `Invitation envoyée à ${friend.displayName}.`, + ); + } + + function revoke(m: PortfolioOwnerDTO) { + setRemoving(null); + void run( + () => api(`/portfolios/${p.id}/members/${encodeURIComponent(m.username)}`, { method: "DELETE" }), + `${m.displayName} n’a plus accès au portfolio.`, + ); + } + + const person = (m: PortfolioOwnerDTO, status: string) => ( +
  1. + + + {m.displayName} + {status} + + +
  2. + ); + + return ( +
    +

    + Partage + + {players} / {PORTFOLIO_SHARED_MAX_PLAYERS} + +

    +

    + Tes amis invités y ajoutent leurs propres cartes, qui restent à eux. Toi seul choisis le titre et le design. +

    + {players > 1 && ( +
      + {p.members.map((m) => person(m, "Participe"))} + {p.invited.map((m) => person(m, "Invitation envoyée"))} +
    + )} + {full ? ( +

    + Le portfolio réunit déjà {PORTFOLIO_SHARED_MAX_PLAYERS} joueurs, toi compris. +

    + ) : friends.error ? ( + friends.mutate()} /> + ) : !friends.data ? ( +
    + ) : candidates.length ? ( +
    + +
    + + +
    +
    + ) : ( +

    + {friends.data.friends.length ? "Tous tes amis sont déjà invités." : "Tu n’as pas encore d’ami à inviter."}{" "} + + Ajouter des amis + +

    + )} + +
    + Qui voit ce portfolio + {VISIBILITIES.map((v) => ( + + ))} +
    + + removing && revoke(removing)} + onClose={() => setRemoving(null)} + > + Ses cartes quittent le portfolio et restent dans sa collection. Tu pourras l’inviter de nouveau. + +
    + ); +} + +/** Ce qu'un ami participant voit à la place du titre et du design : qui compose le portfolio, et le départ. */ +function MemberPanel({ p, me }: { p: PortfolioDTO; me: string }) { + const router = useRouter(); + const [leaving, setLeaving] = useState(false); + + async function leave() { + setLeaving(false); + try { + await api(`/portfolios/${p.id}/members/${encodeURIComponent(me)}`, { method: "DELETE" }); + toast.success("Tu as quitté le portfolio."); + router.push("/portfolios"); + } catch (err) { + toast.error(errorText(err, "Départ impossible.")); + } + } + + return ( +
    +

    + Partage +

    +

    + Portfolio de {p.owner.displayName} : ajoute tes cartes, change l’ordre ou la vedette. Le titre et le design + restent à son choix. +

    +
      + {[p.owner, ...p.members].map((m) => ( +
    • + + {m.username === me ? "Toi" : m.displayName} +
    • + ))} +
    + + setLeaving(false)} + > + Tes cartes en sortent et restent dans ta collection. Il faudra une nouvelle invitation pour revenir. + +
    + ); +} + // --------------------------------------------------------------------------- // Page // --------------------------------------------------------------------------- @@ -689,6 +953,7 @@ export default function EditPortfolioPage({ params }: { params: Promise<{ id: st const { id } = use(params); const router = useRouter(); const { data: p, error, mutate } = useSWR(`/portfolios/${encodeURIComponent(id)}`); + const { me } = useMe(); const [deleting, setDeleting] = useState(false); async function destroy() { @@ -703,10 +968,10 @@ export default function EditPortfolioPage({ params }: { params: Promise<{ id: st } if (error) return mutate()} />; - if (!p) return
    ; - if (!p.mine) + if (!p || !me) return
    ; + if (!participates(p)) return ( - + Voir sa page @@ -720,22 +985,24 @@ export default function EditPortfolioPage({ params }: { params: Promise<{ id: st Portfolios
    - {/* Sur mobile, le design vient après les cartes : raccourci. */} + {/* Sur mobile, le design et le partage viennent après les cartes : raccourci. */} - Design + {p.mine ? "Design et partage" : "Partage"} - {p.cardCount > 0 && ( + {(p.cardCount > 0 || !p.mine) && ( Voir la page )} - + {p.mine && ( + + )}
    -

    Modifier le portfolio

    +

    {p.mine ? "Modifier le portfolio" : "Tes cartes dans ce portfolio"}

    Chaque changement est enregistré aussitôt. Une carte vendue, échangée, recyclée, fusionnée ou upgradée quitte le portfolio. @@ -749,11 +1016,18 @@ export default function EditPortfolioPage({ params }: { params: Promise<{ id: st {p.description &&

    {p.description}

    } - +
    diff --git a/apps/web/src/app/(game)/portfolios/[id]/page.tsx b/apps/web/src/app/(game)/portfolios/[id]/page.tsx index 4ff1dbd..97d15d1 100644 --- a/apps/web/src/app/(game)/portfolios/[id]/page.tsx +++ b/apps/web/src/app/(game)/portfolios/[id]/page.tsx @@ -3,14 +3,18 @@ import type { PortfolioDTO } from "@palacards/shared"; import { ChevronLeft } from "lucide-react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { use } from "react"; import useSWR from "swr"; -import { PortfolioActions, PortfolioView } from "@/components/Portfolio"; +import { InvitationAnswer, PortfolioActions, PortfolioView } from "@/components/Portfolio"; import { ErrorBox } from "@/components/ui"; +import { useMe } from "@/lib/game"; /** Page publique d'un portfolio (lien partageable). */ export default function PortfolioPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); + const { me } = useMe(); + const router = useRouter(); const { data: p, error, mutate } = useSWR(`/portfolios/${encodeURIComponent(id)}`); return ( @@ -21,6 +25,19 @@ export default function PortfolioPage({ params }: { params: Promise<{ id: string {p && }
    + {p?.role === "invited" && me && ( +
    +

    + {p.owner.displayName} t’invite à composer ce portfolio : tu pourras y ajouter tes propres + cartes, qui resteront dans ta collection. +

    + (joined ? void mutate() : router.push("/portfolios"))} + /> +
    + )} {error ? ( mutate()} /> ) : !p ? ( diff --git a/apps/web/src/app/(game)/portfolios/page.tsx b/apps/web/src/app/(game)/portfolios/page.tsx index cb9522f..334b90a 100644 --- a/apps/web/src/app/(game)/portfolios/page.tsx +++ b/apps/web/src/app/(game)/portfolios/page.tsx @@ -1,17 +1,25 @@ "use client"; -import { PORTFOLIO_CARDS_MAX, PORTFOLIO_DESCRIPTION_MAX, PORTFOLIO_MAX, PORTFOLIO_NAME_MAX } from "@palacards/game"; +import { + PORTFOLIO_CARDS_MAX, + PORTFOLIO_DESCRIPTION_MAX, + PORTFOLIO_MAX, + PORTFOLIO_NAME_MAX, + PORTFOLIO_SHARED_MAX_PLAYERS, +} from "@palacards/game"; import type { Page, PortfolioSummaryDTO } from "@palacards/shared"; import { Plus, X } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import { Suspense, useCallback, useEffect, useRef, useState, type FormEvent } from "react"; import { toast } from "sonner"; -import useSWR from "swr"; +import Link from "next/link"; +import useSWR, { useSWRConfig } from "swr"; import useSWRInfinite from "swr/infinite"; -import { PortfolioRow, PortfolioTile } from "@/components/Portfolio"; +import { InvitationAnswer, PortfolioRow, PortfolioShell, PortfolioTile } from "@/components/Portfolio"; import { Empty, ErrorBox, LoadMore } from "@/components/ui"; import { api, ApiError } from "@/lib/api"; import { useSeenFeature } from "@/lib/features"; +import { useMe } from "@/lib/game"; const SORTS = [ { value: "recent", param: null, label: "Récents" }, @@ -133,6 +141,46 @@ function Mine({ onCreate }: { onCreate: () => void }) { ); } +/** Invitations d'amis dans leurs portfolios partagés (rien s'il n'y en a pas). */ +function Invitations() { + const { me } = useMe(); + const { data, mutate } = useSWR("/portfolios/invitations"); + const { mutate: refresh } = useSWRConfig(); + if (!data?.length || !me) return null; + return ( +
    +

    + Invitations +

    +
      + {data.map((p) => ( +
    • + +
      +

      {p.name}

      +

      + {p.owner.displayName} t’invite à y ajouter tes cartes.{" "} + + Voir la page + +

      +
      + { + void mutate(); + void refresh("/portfolios/mine"); + }} + /> +
      +
    • + ))} +
    +
    + ); +} + function Community() { const router = useRouter(); const params = useSearchParams(); @@ -205,9 +253,12 @@ export default function PortfoliosPage() {

    Des pages de classeur composées avec tes cartes : jusqu’à {PORTFOLIO_CARDS_MAX} par portfolio, une en vedette, - et un fond, un cadre et une couleur au choix. Les j’aime ne rapportent rien, ils font juste plaisir. + et un fond, un cadre et une couleur au choix. Invite jusqu’à {PORTFOLIO_SHARED_MAX_PLAYERS - 1} amis à y ajouter + leurs cartes. Les j’aime ne rapportent rien, ils font juste plaisir.

    + +

    Tes portfolios diff --git a/apps/web/src/app/(game)/settings/page.tsx b/apps/web/src/app/(game)/settings/page.tsx index 10f0ae3..fd191b1 100644 --- a/apps/web/src/app/(game)/settings/page.tsx +++ b/apps/web/src/app/(game)/settings/page.tsx @@ -21,7 +21,7 @@ import { setTheme, useTheme } from "@/lib/theme"; const GROUPS: Record = { market: "Marché (enchères, ventes, wishlist)", trades: "Échanges", - social: "Amis et guilde", + social: "Amis, guilde et portfolios partagés", battles: "Duels", packs: "Stock de paquets plein", achievements: "Succès", diff --git a/apps/web/src/components/Portfolio.tsx b/apps/web/src/components/Portfolio.tsx index 87fe531..e18280f 100644 --- a/apps/web/src/components/Portfolio.tsx +++ b/apps/web/src/components/Portfolio.tsx @@ -2,7 +2,7 @@ // Importé uniquement par des composants client. import { PORTFOLIO_PROFILE_SLOTS } from "@palacards/game"; import type { PortfolioDesignDTO, PortfolioDTO, PortfolioSummaryDTO } from "@palacards/shared"; -import { ChevronLeft, ChevronRight, Heart, Link2, Pencil, Star, X } from "lucide-react"; +import { Check, ChevronLeft, ChevronRight, Heart, Link2, Lock, Pencil, Star, Users, X } from "lucide-react"; import Link from "next/link"; import { useState, type CSSProperties, type ReactNode } from "react"; import useSWR from "swr"; @@ -46,6 +46,19 @@ export function PortfolioShell({ const likesLabel = (n: number) => `${fmt(n)} j’aime`; +/** Le visiteur compose ce portfolio (propriétaire ou ami qui l'a rejoint). */ +export const participates = (p: PortfolioSummaryDTO) => p.role === "owner" || p.role === "member"; + +const joinNames = (names: string[]) => + names.length > 1 ? `${names.slice(0, -1).join(", ")} et ${names.at(-1)}` : (names[0] ?? ""); + +/** Ceux qui composent le portfolio : « Léa », « Léa et Max », « Léa, Max et Sam ». */ +export const participantsLabel = (p: PortfolioSummaryDTO) => + joinNames([p.owner, ...p.members].map((m) => m.displayName)); + +/** Amis qui participent au portfolio (sans le propriétaire). */ +const sharedWith = (p: PortfolioSummaryDTO) => joinNames(p.members.map((m) => m.displayName)); + /** J'aime (sans récompense) : un par joueur, jamais sur les siens ; le compteur suit tout de suite. */ export function LikeButton({ p, @@ -58,9 +71,9 @@ export function LikeButton({ const [busy, setBusy] = useState(false); // Nouveau portfolio affiché : on repart de ses valeurs. if (state.for !== p.id) setState({ likes: p.likes, liked: p.liked, for: p.id }); - if (p.mine) + if (participates(p)) return ( - + {fmt(state.likes)} j’aime @@ -117,7 +130,7 @@ export function PortfolioTile({ p, heading: Heading = "h3" }: { p: PortfolioSumm

    - {p.owner.displayName} · {p.cardCount} carte{p.cardCount > 1 ? "s" : ""} + {participantsLabel(p)} · {p.cardCount} carte{p.cardCount > 1 ? "s" : ""}

    @@ -164,14 +177,28 @@ export function PortfolioView({ p }: { p: PortfolioDTO }) {

    {p.name}

    - - - {p.owner.displayName} - + {/* Propriétaire puis amis participants : « Léa, Max et Sam ». */} + {[p.owner, ...p.members].map((m, i, all) => ( + + + + {m.displayName} + + {i < all.length - 2 ? "," : i === all.length - 2 ? et : null} + + ))} · {p.cardCount} carte{p.cardCount > 1 ? "s" : ""} + {p.visibility === "members" && ( + <> + · + + Visible des participants seuls + + + )}

    {p.description &&

    {p.description}

    } @@ -197,20 +224,56 @@ export function PortfolioView({ p }: { p: PortfolioDTO }) { ); } -/** Actions sous la page d'un portfolio : partager, et modifier s'il est à soi. */ +/** Actions sous la page d'un portfolio : partager, et modifier si on le compose. */ export function PortfolioActions({ p }: { p: PortfolioSummaryDTO }) { return (
    - {p.mine && ( + {participates(p) && ( - Modifier + {p.mine ? "Modifier" : "Mes cartes"} )}
    ); } +/** Réponse à l'invitation d'un ami dans son portfolio partagé : rejoindre ou refuser. */ +export function InvitationAnswer({ + p, + me, + onDone, +}: { + p: PortfolioSummaryDTO; + me: string; + onDone: (joined: boolean) => void; +}) { + const [busy, setBusy] = useState(false); + async function answer(join: boolean) { + setBusy(true); + try { + if (join) await api(`/portfolios/${p.id}/join`, { method: "POST" }); + else await api(`/portfolios/${p.id}/members/${encodeURIComponent(me)}`, { method: "DELETE" }); + toast.success(join ? `Tu participes à « ${p.name} ».` : "Invitation refusée."); + onDone(join); + } catch (err) { + toast.error(err instanceof ApiError ? err.message : "Réponse non enregistrée."); + } finally { + setBusy(false); + } + } + return ( +
    + + +
    + ); +} + /** Ligne compacte de ses propres portfolios : la page en petit, trois cartes en éventail et les actions. */ export function PortfolioRow({ p }: { p: PortfolioSummaryDTO }) { return ( @@ -224,13 +287,23 @@ export function PortfolioRow({ p }: { p: PortfolioSummaryDTO }) {

    {p.name}

    - {p.cardCount ? `${p.cardCount} carte${p.cardCount > 1 ? "s" : ""}` : "Vide, visible de toi seul"} ·{" "} - {likesLabel(p.likes)} - {p.profileSlot !== null && " · sur ton profil"} + {p.cardCount + ? `${p.cardCount} carte${p.cardCount > 1 ? "s" : ""}` + : p.members.length + ? "Vide" + : "Vide, visible de toi seul"}{" "} + · {likesLabel(p.likes)} + {p.mine && p.profileSlot !== null && " · sur ton profil"}

    + {(!p.mine || p.members.length > 0) && ( +

    + + {p.mine ? `Avec ${sharedWith(p)}` : `Partagé par ${p.owner.displayName}`} +

    + )}

    - Modifier + {p.mine ? "Modifier" : "Mes cartes"} {p.cardCount > 0 && ( @@ -272,8 +345,10 @@ export function MyProfilePortfolios() { const [adding, setAdding] = useState(""); const [busy, setBusy] = useState(false); if (!data) return null; - const shown = data.filter((p) => p.profileSlot !== null); - const others = data.filter((p) => p.profileSlot === null && p.cardCount > 0); + // Seulement les siens (pas ceux d'amis auxquels on participe), publics pour en ajouter. + const own = data.filter((p) => p.mine); + const shown = own.filter((p) => p.profileSlot !== null); + const others = own.filter((p) => p.profileSlot === null && p.cardCount > 0 && p.visibility === "public"); async function save(ids: number[]) { setBusy(true); @@ -302,7 +377,7 @@ export function MyProfilePortfolios() { {shown.length} / {PORTFOLIO_PROFILE_SLOTS}

    - {data.length > 0 && ( + {own.length > 0 && ( - {shared && card.by !== me && ( - - {names.get(card.by) ?? card.by} - - )} {canRemove(card) && ( )} + {shared && ( +

    + {card.by === me ? "À toi" : `À ${names.get(card.by) ?? card.by}`} +

    + )} ); })} @@ -917,8 +911,8 @@ function MemberPanel({ p, me }: { p: PortfolioDTO; me: string }) { Partage

    - Portfolio de {p.owner.displayName} : ajoute tes cartes, change l’ordre ou la vedette. Le titre et le design - restent à son choix. + Portfolio de {p.owner.displayName} : ajoute tes cartes, change l’ordre ou la vedette. Tu ne retires que les + tiennes ; le titre et le design restent à son choix.

      {[p.owner, ...p.members].map((m) => ( From c045202181ef3295151e93726179dace3daa12d4 Mon Sep 17 00:00:00 2001 From: Palawi Date: Tue, 3 Nov 2026 15:07:43 +0100 Subject: [PATCH 4/4] =?UTF-8?q?test:=20l'invitation=20au=20portfolio=20est?= =?UTF-8?q?=20cherch=C3=A9e=20parmi=20les=20notifications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/test/portfolios.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/api/test/portfolios.test.ts b/apps/api/test/portfolios.test.ts index e5d5f5b..419000b 100644 --- a/apps/api/test/portfolios.test.ts +++ b/apps/api/test/portfolios.test.ts @@ -215,7 +215,9 @@ describe("portfolios partagés (suggestion n° 125)", () => { expect(invited.status).toBe(200); expect(invited.body.invited.map((m: { username: string }) => m.username)).toEqual([friend.username]); expect((await owner.post(`/portfolios/${id}/members`, { username: friend.username })).status).toBe(409); - expect((await friend.get("/notifications")).body.items[0].type).toBe("portfolio_invite"); + // L'invitation figure parmi les notifications (un succès débloqué peut arriver juste après). + const notes = (await friend.get("/notifications")).body.items as { type: string }[]; + expect(notes.map((n) => n.type)).toContain("portfolio_invite"); const invitations = await friend.get("/portfolios/invitations"); expect(invitations.body.map((p: { id: number; role: string }) => [p.id, p.role])).toEqual([[id, "invited"]]);