From 19b01115d417e629525ff72585ab688fd1160891 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 26 Sep 2026 15:22:54 -0500 Subject: [PATCH 01/14] Prove MikroORM 7 and Better Auth integration in Core --- .github/workflows/web.yml | 2 +- packages/core/package.json | 10 +- packages/core/src/index.ts | 4 + packages/core/src/mikro/auth-entities.test.ts | 68 ++ packages/core/src/mikro/auth-entities.ts | 67 ++ packages/core/src/mikro/auth.ts | 13 + packages/core/src/mikro/profile.ts | 23 + pnpm-lock.yaml | 658 +++++++++++++++++- 8 files changed, 832 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/mikro/auth-entities.test.ts create mode 100644 packages/core/src/mikro/auth-entities.ts create mode 100644 packages/core/src/mikro/auth.ts create mode 100644 packages/core/src/mikro/profile.ts diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 7c584089..664770b6 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -40,7 +40,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22.17.0" cache: pnpm - name: Install dependencies diff --git a/packages/core/package.json b/packages/core/package.json index b7f8be9b..28f03091 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -20,10 +20,18 @@ "test": "vitest run" }, "dependencies": { + "@a77ay/better-auth-mikro-orm": "0.1.1", + "@mikro-orm/core": "7.2.1", + "@mikro-orm/libsql": "7.2.1", + "@mikro-orm/migrations": "7.2.1", + "@mikro-orm/mysql": "7.2.1", + "@mikro-orm/postgresql": "7.2.1", + "@mikro-orm/sqlite": "7.2.1", + "better-auth": "1.7.6", "zod": "^3.25.67" }, "devDependencies": { - "typescript": "5.5.3", + "typescript": "5.9.3", "vitest": "^3.2.4" }, "license": "MIT", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b5e1f2a8..863b5adf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -20,6 +20,10 @@ export type { LogLevel, } from "./adapters/logger"; export { createHackkit } from "./hackkit"; +export { createHackKitAuth } from "./mikro/auth"; +export type { HackKitAuthOptions } from "./mikro/auth"; +export { authEntities, AuthUser, AuthSession, AuthAccount, AuthVerification } from "./mikro/auth-entities"; +export { HackKitProfile } from "./mikro/profile"; export type { HackKit } from "./hackkit"; export { seedRoles } from "./seed"; export type { SeedRoleInput, SeedRolesOptions, SeedRolesResult } from "./seed"; diff --git a/packages/core/src/mikro/auth-entities.test.ts b/packages/core/src/mikro/auth-entities.test.ts new file mode 100644 index 00000000..454fe3e1 --- /dev/null +++ b/packages/core/src/mikro/auth-entities.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { MikroORM, RequestContext } from "@mikro-orm/core"; +import { SqliteDriver } from "@mikro-orm/sqlite"; +import { LibSqlDriver } from "@mikro-orm/libsql"; +import { AuthUser, authEntities } from "./auth-entities"; +import { createHackKitAuth } from "./auth"; +import { HackKitProfile } from "./profile"; + +describe.each([ + ["SQLite", () => MikroORM.init({ driver: SqliteDriver, dbName: ":memory:", entities: [...authEntities, HackKitProfile] })], + ["local libSQL", () => MikroORM.init({ driver: LibSqlDriver, dbName: ":memory:", entities: [...authEntities, HackKitProfile] })], +])("Better Auth MikroORM integration on %s", (_name, open) => { + it("persists signup and session with a request-scoped manager", async () => { + const orm = await open(); + try { + await orm.schema.create(); + const auth = createHackKitAuth(orm, { + secret: "test-secret-that-is-at-least-32-characters", + baseURL: "http://localhost:3000", + emailAndPassword: { enabled: true }, + }); + await RequestContext.create(orm.em, async () => { + const result = await auth.api.signUpEmail({ + body: { + name: "Example User", + email: "example@example.com", + password: "correct horse battery staple", + }, + asResponse: true, + }); + expect(result.status).toBe(200); + const cookie = result.headers.get("set-cookie"); + expect(cookie).toContain("better-auth.session_token"); + const session = await auth.api.getSession({ + headers: new Headers({ cookie: cookie ?? "" }), + }); + expect(session?.user.email).toBe("example@example.com"); + }); + const users = await orm.em.fork().find(AuthUser, {}); + expect(users).toHaveLength(1); + const em = orm.em.fork(); + const profile = em.create(HackKitProfile, { + user: users[0], + firstName: "Example", + lastName: "User", + hackTag: "example", + }); + await em.persist(profile).flush(); + expect((await em.fork().findOneOrFail(HackKitProfile, { hackTag: "example" })).skills).toEqual([]); + const duplicateManager = em.fork(); + await expect(duplicateManager.persist( + duplicateManager.create(HackKitProfile, { + user: users[0], firstName: "Duplicate", lastName: "User", + }), + ).flush()).rejects.toThrow(); + await expect(em.transactional(async (transaction) => { + const value = await transaction.findOneOrFail(HackKitProfile, { hackTag: "example" }); + value.bio = "rolled back"; + await transaction.flush(); + throw new Error("abort"); + })).rejects.toThrow("abort"); + expect((await em.fork().findOneOrFail(HackKitProfile, { hackTag: "example" })).bio).toBeNull(); + expect(await orm.schema.getUpdateSchemaSQL()).toBe(""); + } finally { + await orm.close(); + } + }); +}); diff --git a/packages/core/src/mikro/auth-entities.ts b/packages/core/src/mikro/auth-entities.ts new file mode 100644 index 00000000..101d81a9 --- /dev/null +++ b/packages/core/src/mikro/auth-entities.ts @@ -0,0 +1,67 @@ +import { defineEntity, p } from "@mikro-orm/core"; + +// These are Better Auth's default models. Changes to Better Auth options that add +// columns or plugins must regenerate the auth entities before a migration is made. +export const AuthUser = defineEntity({ + name: "AuthUser", + tableName: "user", + properties: { + id: p.string().primary(), + name: p.string(), + email: p.string().unique(), + emailVerified: p.boolean().default(false), + image: p.string().nullable(), + createdAt: p.datetime(), + updatedAt: p.datetime(), + }, +}); + +export const AuthSession = defineEntity({ + name: "AuthSession", + tableName: "session", + properties: { + id: p.string().primary(), + expiresAt: p.datetime(), + token: p.string().unique(), + createdAt: p.datetime(), + updatedAt: p.datetime(), + ipAddress: p.string().nullable(), + userAgent: p.string().nullable(), + userId: p.string().index(), + }, +}); + +export const AuthAccount = defineEntity({ + name: "AuthAccount", + tableName: "account", + properties: { + id: p.string().primary(), + accountId: p.string(), + providerId: p.string(), + userId: p.string().index(), + accessToken: p.string().nullable(), + refreshToken: p.string().nullable(), + idToken: p.string().nullable(), + accessTokenExpiresAt: p.datetime().nullable(), + refreshTokenExpiresAt: p.datetime().nullable(), + scope: p.string().nullable(), + password: p.string().nullable(), + createdAt: p.datetime(), + updatedAt: p.datetime(), + }, +}); + +export const AuthVerification = defineEntity({ + name: "AuthVerification", + tableName: "verification", + properties: { + id: p.string().primary(), + identifier: p.string().index(), + value: p.string(), + expiresAt: p.datetime(), + createdAt: p.datetime().nullable(), + updatedAt: p.datetime().nullable(), + }, +}); + +export const authEntities = [AuthUser, AuthSession, AuthAccount, AuthVerification]; diff --git a/packages/core/src/mikro/auth.ts b/packages/core/src/mikro/auth.ts new file mode 100644 index 00000000..93a68ff9 --- /dev/null +++ b/packages/core/src/mikro/auth.ts @@ -0,0 +1,13 @@ +import type { MikroORM } from "@mikro-orm/core"; +import { mikroOrmAdapter } from "@a77ay/better-auth-mikro-orm"; +import { betterAuth, type BetterAuthOptions } from "better-auth"; + +export type HackKitAuthOptions = Omit; + +/** The application supplies credentials and providers; Core owns the database adapter. */ +export function createHackKitAuth(orm: MikroORM, options: HackKitAuthOptions) { + return betterAuth({ + ...options, + database: mikroOrmAdapter(orm), + }); +} diff --git a/packages/core/src/mikro/profile.ts b/packages/core/src/mikro/profile.ts new file mode 100644 index 00000000..18fe1d06 --- /dev/null +++ b/packages/core/src/mikro/profile.ts @@ -0,0 +1,23 @@ +import { defineEntity, p } from "@mikro-orm/core"; +import { AuthUser } from "./auth-entities"; + +export const HackKitProfile = defineEntity({ + name: "HackKitProfile", + tableName: "core_user", + properties: { + user: () => p.oneToOne(AuthUser).owner().primary().deleteRule("cascade"), + firstName: p.string(), + lastName: p.string(), + profilePhotoUrl: p.string().nullable(), + hackTag: p.string().nullable().unique(), + bio: p.string().nullable(), + pronouns: p.string().nullable(), + skills: p.json().default([]), + isProfileSearchable: p.boolean().default(true), + discordDisplayHandle: p.string().nullable(), + isApproved: p.boolean().default(false), + checkedInAt: p.datetime().nullable(), + createdAt: p.datetime().onCreate(() => new Date()), + updatedAt: p.datetime().onCreate(() => new Date()).onUpdate(() => new Date()), + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b73723b..c8dc41ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,7 +13,7 @@ importers: devDependencies: '@turbo/gen': specifier: ^2.0.9 - version: 2.0.9(@types/node@20.14.11)(typescript@5.5.3) + version: 2.0.9(@types/node@20.14.11)(typescript@5.9.3) dotenv-cli: specifier: ^7.4.2 version: 7.4.2 @@ -221,13 +221,37 @@ importers: packages/core: dependencies: + '@a77ay/better-auth-mikro-orm': + specifier: 0.1.1 + version: 0.1.1(@mikro-orm/core@7.2.1)(better-auth@1.7.6) + '@mikro-orm/core': + specifier: 7.2.1 + version: 7.2.1 + '@mikro-orm/libsql': + specifier: 7.2.1 + version: 7.2.1(@mikro-orm/core@7.2.1) + '@mikro-orm/migrations': + specifier: 7.2.1 + version: 7.2.1(@mikro-orm/core@7.2.1) + '@mikro-orm/mysql': + specifier: 7.2.1 + version: 7.2.1(@mikro-orm/core@7.2.1)(@types/node@20.14.11) + '@mikro-orm/postgresql': + specifier: 7.2.1 + version: 7.2.1(@mikro-orm/core@7.2.1) + '@mikro-orm/sqlite': + specifier: 7.2.1 + version: 7.2.1(@mikro-orm/core@7.2.1) + better-auth: + specifier: 1.7.6 + version: 1.7.6(pg@8.23.0)(vitest@3.2.4) zod: specifier: ^3.25.67 version: 3.25.67 devDependencies: typescript: - specifier: 5.5.3 - version: 5.5.3 + specifier: 5.9.3 + version: 5.9.3 vitest: specifier: ^3.2.4 version: 3.2.4(@types/node@20.14.11)(jiti@2.4.2) @@ -449,6 +473,18 @@ importers: packages: + /@a77ay/better-auth-mikro-orm@0.1.1(@mikro-orm/core@7.2.1)(better-auth@1.7.6): + resolution: {integrity: sha512-WT0EwaIwG6TI0crFDD5bzF823p2o4qMVgSCDFqqzw2pdWSA0TzSVTL8GGjUxNTiPFgOVjFc/GLQnKSYVjnQaPg==} + hasBin: true + peerDependencies: + '@mikro-orm/core': ^7.0.0 + better-auth: ^1.0.0 + dependencies: + '@mikro-orm/core': 7.2.1 + better-auth: 1.7.6(pg@8.23.0)(vitest@3.2.4) + jiti: 2.7.0 + dev: false + /@alloc/quick-lru@5.2.0: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -1474,7 +1510,32 @@ packages: jose: 6.2.3 kysely: 0.28.17 nanostores: 1.4.0 - zod: 4.4.3 + zod: 4.6.5 + + /@better-auth/core@1.7.6(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0)(jose@6.2.3)(kysely@0.29.6)(nanostores@1.4.0): + resolution: {integrity: sha512-yBDDO0J4VCHT1qEZtzj6aMexX4etyLesitoxehcWHzPrzYqrFHwe/hgskye+imlGhQVOysuljMg8M8BA6GrtIg==} + peerDependencies: + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.2 + '@opentelemetry/api': ^1.9.0 + better-call: 1.4.0 + jose: ^6.1.0 + kysely: ^0.28.5 || ^0.29.0 + nanostores: ^1.0.1 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + dependencies: + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.2 + '@opentelemetry/semantic-conventions': 1.43.0 + '@standard-schema/spec': 1.1.0 + better-call: 1.4.0(zod@3.25.67) + jose: 6.2.3 + kysely: 0.29.6 + nanostores: 1.4.0 + zod: 4.6.5 + dev: false /@better-auth/drizzle-adapter@1.6.11(@better-auth/core@1.6.11)(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2): resolution: {integrity: sha512-4jpkETIGZOHCf7BK4jnu22fdN6jjomH0/HhEzkaWy3+Eppi5PYlHTF/460jrTmA3Xc+Vqwp9t282ymHiEPypGw==} @@ -1490,6 +1551,20 @@ packages: '@better-auth/utils': 0.4.0 drizzle-orm: 0.45.2(@libsql/client@0.14.0)(kysely@0.28.17) + /@better-auth/drizzle-adapter@1.7.6(@better-auth/core@1.7.6)(@better-auth/utils@0.4.2): + resolution: {integrity: sha512-ENlhwC7kkTjquuiel3Efggik0hC4vtD7ppJ8FhsiHhRJA8K6RT4hbhzkfm6keNvjdnAeWwCejm7FmMO4GoSGpA==} + peerDependencies: + '@better-auth/core': ^1.7.6 + '@better-auth/utils': 0.4.2 + drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 + peerDependenciesMeta: + drizzle-orm: + optional: true + dependencies: + '@better-auth/core': 1.7.6(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0)(jose@6.2.3)(kysely@0.29.6)(nanostores@1.4.0) + '@better-auth/utils': 0.4.2 + dev: false + /@better-auth/kysely-adapter@1.6.11(@better-auth/core@1.6.11)(@better-auth/utils@0.4.0)(kysely@0.28.17): resolution: {integrity: sha512-/g8M9RfIjdcZDnbstSUvQiINkvdNlCeZr248zwqx2/PVksQI1MhQofbzUn3RnQnbPKp0EPwpX/dR3oudRFenUg==} peerDependencies: @@ -1504,6 +1579,21 @@ packages: '@better-auth/utils': 0.4.0 kysely: 0.28.17 + /@better-auth/kysely-adapter@1.7.6(@better-auth/core@1.7.6)(@better-auth/utils@0.4.2)(kysely@0.29.6): + resolution: {integrity: sha512-BV+DX2/z/6ozNC9DYSge78wjLerq6Bxd/lSPjzzuq9eEutkQDAJTuiaETzgGYqNV5LlEWi8TZFAQTEq/wavOEQ==} + peerDependencies: + '@better-auth/core': ^1.7.6 + '@better-auth/utils': 0.4.2 + kysely: ^0.28.17 || ^0.29.0 + peerDependenciesMeta: + kysely: + optional: true + dependencies: + '@better-auth/core': 1.7.6(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0)(jose@6.2.3)(kysely@0.29.6)(nanostores@1.4.0) + '@better-auth/utils': 0.4.2 + kysely: 0.29.6 + dev: false + /@better-auth/memory-adapter@1.6.11(@better-auth/core@1.6.11)(@better-auth/utils@0.4.0): resolution: {integrity: sha512-hpdfw0BBf8MuzLkIdmbcUZICbY9r/bhLO2RxSnkzT5+/O+0I0u2I8+m0YUP7vNllP/ZCKASHOYgXPLO75Z0f9Q==} peerDependencies: @@ -1513,6 +1603,16 @@ packages: '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5)(jose@6.2.3)(kysely@0.28.17)(nanostores@1.4.0) '@better-auth/utils': 0.4.0 + /@better-auth/memory-adapter@1.7.6(@better-auth/core@1.7.6)(@better-auth/utils@0.4.2): + resolution: {integrity: sha512-ng1xv8k2JsEIjnqzJzdoVOQrnoCLRuk4EiQL9yYAThyg6zYzV00LynlhH+O9OMUZ+yGVpGqGZmr5xcTiUSV2uA==} + peerDependencies: + '@better-auth/core': ^1.7.6 + '@better-auth/utils': 0.4.2 + dependencies: + '@better-auth/core': 1.7.6(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0)(jose@6.2.3)(kysely@0.29.6)(nanostores@1.4.0) + '@better-auth/utils': 0.4.2 + dev: false + /@better-auth/mongo-adapter@1.6.11(@better-auth/core@1.6.11)(@better-auth/utils@0.4.0): resolution: {integrity: sha512-3Tor8rSv8vSEIMEaV2PFpPEuVhqc1gNoZ6eGvoh3LwExXXuj8madew6ob+H1pH7Aphn3Ar5PQ08AguT8TbwFAA==} peerDependencies: @@ -1526,6 +1626,20 @@ packages: '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5)(jose@6.2.3)(kysely@0.28.17)(nanostores@1.4.0) '@better-auth/utils': 0.4.0 + /@better-auth/mongo-adapter@1.7.6(@better-auth/core@1.7.6)(@better-auth/utils@0.4.2): + resolution: {integrity: sha512-GvBwZli8i4XINMAj9tlW7eV9E8Dr+JWqavXjUs2o81GZt/Vh8wiX+31KK1jQ4iOuejfjpLoXcoFtlIPgf/pVog==} + peerDependencies: + '@better-auth/core': ^1.7.6 + '@better-auth/utils': 0.4.2 + mongodb: ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + mongodb: + optional: true + dependencies: + '@better-auth/core': 1.7.6(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0)(jose@6.2.3)(kysely@0.29.6)(nanostores@1.4.0) + '@better-auth/utils': 0.4.2 + dev: false + /@better-auth/prisma-adapter@1.6.11(@better-auth/core@1.6.11)(@better-auth/utils@0.4.0): resolution: {integrity: sha512-Pw+7q7zTp+VSci1V+CYMvuxIbAeVMZLe4lRo46LJoAKMHfjFl5T/ycsyFvWs/DkWC7n9gZZzRDEbHp0I5FiKKw==} peerDependencies: @@ -1542,6 +1656,23 @@ packages: '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5)(jose@6.2.3)(kysely@0.28.17)(nanostores@1.4.0) '@better-auth/utils': 0.4.0 + /@better-auth/prisma-adapter@1.7.6(@better-auth/core@1.7.6)(@better-auth/utils@0.4.2): + resolution: {integrity: sha512-31rz2OXIx2Q1L4Jhd5F71dafIWqder/Bo6vJ+eCgO5iZ0gvnv7fUmM95DvbVUoPC69XYpcIM/6PJmQ3M6RydWg==} + peerDependencies: + '@better-auth/core': ^1.7.6 + '@better-auth/utils': 0.4.2 + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + '@prisma/client': + optional: true + prisma: + optional: true + dependencies: + '@better-auth/core': 1.7.6(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0)(jose@6.2.3)(kysely@0.29.6)(nanostores@1.4.0) + '@better-auth/utils': 0.4.2 + dev: false + /@better-auth/telemetry@1.6.11(@better-auth/core@1.6.11)(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21): resolution: {integrity: sha512-hsjDHc8MZbm6/AHeNdtywrWedXevnBjmdvnHTcZub+rTVjOv+Td0roI8USKuC6uUibmrl//2rJfVCsGbopihNA==} peerDependencies: @@ -1553,14 +1684,42 @@ packages: '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 + /@better-auth/telemetry@1.7.6(@better-auth/core@1.7.6)(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2): + resolution: {integrity: sha512-xgRwPja2wTJtcu+OQZR2IiNpAmviddnW3n5Do5umXRugJFW3v9KlSmYjYm580mVsPnU4upvZkvSRl9v8DgSDtA==} + peerDependencies: + '@better-auth/core': ^1.7.6 + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.2 + dependencies: + '@better-auth/core': 1.7.6(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0)(jose@6.2.3)(kysely@0.29.6)(nanostores@1.4.0) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.2 + dev: false + /@better-auth/utils@0.4.0: resolution: {integrity: sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA==} dependencies: '@noble/hashes': 2.2.0 + /@better-auth/utils@0.4.2: + resolution: {integrity: sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==} + dependencies: + '@noble/hashes': 2.2.0 + dev: false + + /@better-auth/utils@0.5.0: + resolution: {integrity: sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==} + dependencies: + '@noble/hashes': 2.2.0 + dev: false + /@better-fetch/fetch@1.1.21: resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + /@better-fetch/fetch@1.3.2: + resolution: {integrity: sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow==} + dev: false + /@chevrotain/cst-dts-gen@10.5.0: resolution: {integrity: sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==} dependencies: @@ -2409,6 +2568,14 @@ packages: requiresBuild: true optional: true + /@libsql/darwin-arm64@0.5.29: + resolution: {integrity: sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A==} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + /@libsql/darwin-x64@0.4.7: resolution: {integrity: sha512-ezc7V75+eoyyH07BO9tIyJdqXXcRfZMbKcLCeF8+qWK5nP8wWuMcfOVywecsXGRbT99zc5eNra4NEx6z5PkSsA==} cpu: [x64] @@ -2416,6 +2583,14 @@ packages: requiresBuild: true optional: true + /@libsql/darwin-x64@0.5.29: + resolution: {integrity: sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ==} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + /@libsql/hrana-client@0.7.0: resolution: {integrity: sha512-OF8fFQSkbL7vJY9rfuegK1R7sPgQ6kFMkDamiEccNUvieQ+3urzfDFI616oPl8V7T9zRmnTkSjMOImYCAVRVuw==} dependencies: @@ -2440,6 +2615,22 @@ packages: - bufferutil - utf-8-validate + /@libsql/linux-arm-gnueabihf@0.5.29: + resolution: {integrity: sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ==} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@libsql/linux-arm-musleabihf@0.5.29: + resolution: {integrity: sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg==} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: false + optional: true + /@libsql/linux-arm64-gnu@0.4.7: resolution: {integrity: sha512-WlX2VYB5diM4kFfNaYcyhw5y+UJAI3xcMkEUJZPtRDEIu85SsSFrQ+gvoKfcVh76B//ztSeEX2wl9yrjF7BBCA==} cpu: [arm64] @@ -2447,6 +2638,14 @@ packages: requiresBuild: true optional: true + /@libsql/linux-arm64-gnu@0.5.29: + resolution: {integrity: sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w==} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: false + optional: true + /@libsql/linux-arm64-musl@0.4.7: resolution: {integrity: sha512-6kK9xAArVRlTCpWeqnNMCoXW1pe7WITI378n4NpvU5EJ0Ok3aNTIC2nRPRjhro90QcnmLL1jPcrVwO4WD1U0xw==} cpu: [arm64] @@ -2454,6 +2653,14 @@ packages: requiresBuild: true optional: true + /@libsql/linux-arm64-musl@0.5.29: + resolution: {integrity: sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg==} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: false + optional: true + /@libsql/linux-x64-gnu@0.4.7: resolution: {integrity: sha512-CMnNRCmlWQqqzlTw6NeaZXzLWI8bydaXDke63JTUCvu8R+fj/ENsLrVBtPDlxQ0wGsYdXGlrUCH8Qi9gJep0yQ==} cpu: [x64] @@ -2461,6 +2668,14 @@ packages: requiresBuild: true optional: true + /@libsql/linux-x64-gnu@0.5.29: + resolution: {integrity: sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: false + optional: true + /@libsql/linux-x64-musl@0.4.7: resolution: {integrity: sha512-nI6tpS1t6WzGAt1Kx1n1HsvtBbZ+jHn0m7ogNNT6pQHZQj7AFFTIMeDQw/i/Nt5H38np1GVRNsFe99eSIMs9XA==} cpu: [x64] @@ -2468,6 +2683,14 @@ packages: requiresBuild: true optional: true + /@libsql/linux-x64-musl@0.5.29: + resolution: {integrity: sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: false + optional: true + /@libsql/win32-x64-msvc@0.4.7: resolution: {integrity: sha512-7pJzOWzPm6oJUxml+PCDRzYQ4A1hTMHAciTAHfFK4fkbDZX33nWPVG7Y3vqdKtslcwAzwmrNDc6sXy2nwWnbiw==} cpu: [x64] @@ -2475,6 +2698,101 @@ packages: requiresBuild: true optional: true + /@libsql/win32-x64-msvc@0.5.29: + resolution: {integrity: sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg==} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: false + optional: true + + /@mikro-orm/core@7.2.1: + resolution: {integrity: sha512-+gtJoScbin/C3wPItSZkj5vlMTq/LphBLIxHyi5brCbeA6VDcYuoMopiqoFUMQxdhXXrZlKNIHXGrhb8r3biVg==} + engines: {node: '>= 22.17.0'} + peerDependencies: + dataloader: 2.2.3 + peerDependenciesMeta: + dataloader: + optional: true + dev: false + + /@mikro-orm/libsql@7.2.1(@mikro-orm/core@7.2.1): + resolution: {integrity: sha512-jXvptoHpbzfj8XUy5V5BvVee2LOsxgI/GnITfmh6Jl42tpIEDJx6Talqbv+z1PNGkHhw8OU5GoHXDJqYpNqo5w==} + engines: {node: '>= 22.17.0'} + peerDependencies: + '@mikro-orm/core': 7.2.1 + dependencies: + '@mikro-orm/core': 7.2.1 + '@mikro-orm/sql': 7.2.1(@mikro-orm/core@7.2.1) + kysely: 0.29.6 + libsql: 0.5.29 + dev: false + + /@mikro-orm/migrations@7.2.1(@mikro-orm/core@7.2.1): + resolution: {integrity: sha512-sxGcB7na0AkcREsD5U3xP+Psb/nhQFkx7/eY1Nsqq9MBs67O5RoCL97bn3lmgq6GwKZFbktMuuOGozq42+GaNg==} + engines: {node: '>= 22.17.0'} + peerDependencies: + '@mikro-orm/core': 7.2.1 + dependencies: + '@mikro-orm/core': 7.2.1 + '@mikro-orm/sql': 7.2.1(@mikro-orm/core@7.2.1) + dev: false + + /@mikro-orm/mysql@7.2.1(@mikro-orm/core@7.2.1)(@types/node@20.14.11): + resolution: {integrity: sha512-IxCEnfHUzFcxVkRCVaC0KtGoHRRLogxxgeI+J4Pk7oxtDf/jtvYQuFL4tSbmlrH3qzmLQeYYPyJPuDr+a2F7ag==} + engines: {node: '>= 22.17.0'} + peerDependencies: + '@mikro-orm/core': 7.2.1 + dependencies: + '@mikro-orm/core': 7.2.1 + '@mikro-orm/sql': 7.2.1(@mikro-orm/core@7.2.1) + kysely: 0.29.6 + mysql2: 3.24.4(@types/node@20.14.11) + sqlstring: 2.3.3 + transitivePeerDependencies: + - '@types/node' + dev: false + + /@mikro-orm/postgresql@7.2.1(@mikro-orm/core@7.2.1): + resolution: {integrity: sha512-PQ1qe4Bj/hQSHYJjR02l06FfEDwniAvncduwZSCLSKuaCUddHc5AN2Utj3yo4xRIVRY8pOa7DFitEQPBozTMJA==} + engines: {node: '>= 22.17.0'} + peerDependencies: + '@mikro-orm/core': 7.2.1 + dependencies: + '@mikro-orm/core': 7.2.1 + '@mikro-orm/sql': 7.2.1(@mikro-orm/core@7.2.1) + kysely: 0.29.6 + pg: 8.23.0 + pg-cursor: 2.22.0(pg@8.23.0) + postgres-array: 3.0.4 + postgres-date: 2.1.0 + postgres-interval: 4.1.0 + transitivePeerDependencies: + - pg-native + dev: false + + /@mikro-orm/sql@7.2.1(@mikro-orm/core@7.2.1): + resolution: {integrity: sha512-3M9dQeUDLlvAT5VJr/KBGAk9C7xez+psVCnvNkAO4hRhwx2Wm7N1HkuyP4Bwj0MjkHVB6LYvhTw/V6pYsVM8lA==} + engines: {node: '>= 22.17.0'} + peerDependencies: + '@mikro-orm/core': 7.2.1 + dependencies: + '@mikro-orm/core': 7.2.1 + kysely: 0.29.6 + dev: false + + /@mikro-orm/sqlite@7.2.1(@mikro-orm/core@7.2.1): + resolution: {integrity: sha512-+iAPGORAqLl+TcBF+JSLOfRq+PwTQG1NLts+AvN9D8C2czlbDpyjyeaAFSR+5wJlMEZ4wv2rdzt4jSS5T7x3XQ==} + engines: {node: '>= 22.17.0'} + peerDependencies: + '@mikro-orm/core': 7.2.1 + dependencies: + '@mikro-orm/core': 7.2.1 + '@mikro-orm/sql': 7.2.1(@mikro-orm/core@7.2.1) + better-sqlite3: 13.0.3 + kysely: 0.29.6 + dev: false + /@mrleebo/prisma-ast@0.13.1: resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==} engines: {node: '>=16'} @@ -3817,7 +4135,7 @@ packages: resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} dev: true - /@turbo/gen@2.0.9(@types/node@20.14.11)(typescript@5.5.3): + /@turbo/gen@2.0.9(@types/node@20.14.11)(typescript@5.9.3): resolution: {integrity: sha512-IVCBa/+qxsO7srAvFpto0AF09Zei+zpDoosUsHSEwgn/22rVuZ6hdcJdxcQ9kHeMafJpxqLK9iHNSdXUctJ0Vw==} hasBin: true dependencies: @@ -3829,7 +4147,7 @@ packages: node-plop: 0.26.3 picocolors: 1.0.1 proxy-agent: 6.5.0 - ts-node: 10.9.2(@types/node@20.14.11)(typescript@5.5.3) + ts-node: 10.9.2(@types/node@20.14.11)(typescript@5.9.3) update-check: 1.5.4 validate-npm-package-name: 5.0.1 transitivePeerDependencies: @@ -4221,6 +4539,11 @@ packages: postcss-value-parser: 4.2.0 dev: true + /aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + dev: false + /balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} dev: true @@ -4335,6 +4658,88 @@ packages: - '@cloudflare/workers-types' - '@opentelemetry/api' + /better-auth@1.7.6(pg@8.23.0)(vitest@3.2.4): + resolution: {integrity: sha512-WTMqOpmTTj+oBoX7zzPOzL85342Upyf+/v0IkH1kvGRA85lV4JGRFIYMIRKqvjZ6ShsuL5VX/c9C13jtoZXcKA==} + peerDependencies: + '@lynx-js/react': '*' + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + '@sveltejs/kit': ^2.0.0 + '@tanstack/react-start': ^1.0.0 + '@tanstack/solid-start': ^1.0.0 + drizzle-kit: '>=0.31.4 || >=1.0.0-beta.1' + drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 + mongodb: ^6.0.0 || ^7.0.0 + mysql2: ^3.0.0 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + pg: ^8.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + solid-js: ^1.0.0 + svelte: ^4.0.0 || ^5.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@lynx-js/react': + optional: true + '@prisma/client': + optional: true + '@sveltejs/kit': + optional: true + '@tanstack/react-start': + optional: true + '@tanstack/solid-start': + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + mongodb: + optional: true + mysql2: + optional: true + next: + optional: true + pg: + optional: true + prisma: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vitest: + optional: true + vue: + optional: true + dependencies: + '@better-auth/core': 1.7.6(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0)(jose@6.2.3)(kysely@0.29.6)(nanostores@1.4.0) + '@better-auth/drizzle-adapter': 1.7.6(@better-auth/core@1.7.6)(@better-auth/utils@0.4.2) + '@better-auth/kysely-adapter': 1.7.6(@better-auth/core@1.7.6)(@better-auth/utils@0.4.2)(kysely@0.29.6) + '@better-auth/memory-adapter': 1.7.6(@better-auth/core@1.7.6)(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.7.6(@better-auth/core@1.7.6)(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.7.6(@better-auth/core@1.7.6)(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.7.6(@better-auth/core@1.7.6)(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.2 + '@noble/ciphers': 2.2.0 + '@noble/hashes': 2.2.0 + better-call: 1.4.0(zod@3.25.67) + defu: 6.1.7 + jose: 6.2.3 + kysely: 0.29.6 + nanostores: 1.4.0 + pg: 8.23.0 + vitest: 3.2.4(@types/node@20.14.11)(jiti@2.4.2) + zod: 4.6.5 + transitivePeerDependencies: + - '@opentelemetry/api' + dev: false + /better-call@1.3.5(zod@4.4.3): resolution: {integrity: sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA==} peerDependencies: @@ -4349,6 +4754,29 @@ packages: set-cookie-parser: 3.1.2 zod: 4.4.3 + /better-call@1.4.0(zod@3.25.67): + resolution: {integrity: sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + dependencies: + '@better-auth/utils': 0.5.0 + '@better-fetch/fetch': 1.3.2 + rou3: 0.9.2 + set-cookie-parser: 3.1.2 + zod: 3.25.67 + dev: false + + /better-sqlite3@13.0.3: + resolution: {integrity: sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==} + engines: {node: '>=22'} + requiresBuild: true + dependencies: + node-addon-api: 8.9.2 + dev: false + /binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -5483,6 +5911,12 @@ packages: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} dev: true + /generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + dependencies: + is-property: 1.0.2 + dev: false + /gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -5659,7 +6093,6 @@ packages: engines: {node: '>=0.10.0'} dependencies: safer-buffer: 2.1.2 - dev: true /ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -5827,6 +6260,10 @@ packages: engines: {node: '>=8'} dev: true + /is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + dev: false + /is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -5871,7 +6308,6 @@ packages: /jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true - dev: true /jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} @@ -5937,6 +6373,11 @@ packages: resolution: {integrity: sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==} engines: {node: '>=20.0.0'} + /kysely@0.29.6: + resolution: {integrity: sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg==} + engines: {node: '>=22.0.0'} + dev: false + /levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -5961,6 +6402,25 @@ packages: '@libsql/linux-x64-musl': 0.4.7 '@libsql/win32-x64-msvc': 0.4.7 + /libsql@0.5.29: + resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==} + cpu: [x64, arm64, wasm32, arm] + os: [darwin, linux, win32] + dependencies: + '@neon-rs/load': 0.0.4 + detect-libc: 2.0.2 + optionalDependencies: + '@libsql/darwin-arm64': 0.5.29 + '@libsql/darwin-x64': 0.5.29 + '@libsql/linux-arm-gnueabihf': 0.5.29 + '@libsql/linux-arm-musleabihf': 0.5.29 + '@libsql/linux-arm64-gnu': 0.5.29 + '@libsql/linux-arm64-musl': 0.5.29 + '@libsql/linux-x64-gnu': 0.5.29 + '@libsql/linux-x64-musl': 0.5.29 + '@libsql/win32-x64-msvc': 0.5.29 + dev: false + /lilconfig@2.1.0: resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} engines: {node: '>=10'} @@ -6014,6 +6474,10 @@ packages: is-unicode-supported: 0.1.0 dev: true + /long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + dev: false + /loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -6043,6 +6507,11 @@ packages: engines: {node: '>=12'} dev: true + /lru.min@1.1.5: + resolution: {integrity: sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + dev: false + /magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} dependencies: @@ -6105,6 +6574,22 @@ packages: resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} dev: true + /mysql2@3.24.4(@types/node@20.14.11): + resolution: {integrity: sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA==} + engines: {node: '>= 8.0'} + peerDependencies: + '@types/node': '>= 8' + dependencies: + '@types/node': 20.14.11 + aws-ssl-profiles: 1.1.2 + generate-function: 2.3.1 + iconv-lite: 0.7.3 + long: 5.3.2 + lru.min: 1.1.5 + named-placeholders: 1.1.6 + sql-escaper: 1.5.2 + dev: false + /mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} dependencies: @@ -6113,6 +6598,13 @@ packages: thenify-all: 1.6.0 dev: true + /named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + dependencies: + lru.min: 1.1.5 + dev: false + /nanoid@3.3.15: resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -6182,6 +6674,11 @@ packages: lower-case: 1.1.4 dev: true + /node-addon-api@8.9.2: + resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==} + engines: {node: ^18 || ^20 || >= 21} + dev: false + /node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -6427,6 +6924,76 @@ packages: resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} dev: true + /pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + requiresBuild: true + dev: false + optional: true + + /pg-connection-string@2.14.0: + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} + dev: false + + /pg-cursor@2.22.0(pg@8.23.0): + resolution: {integrity: sha512-knzXLKqarTjOvb3qDSW0JiGsazmxwEKXrqHfWRte7XUsOYccQRafn3BLnQobWwInkzFJSyOej8y8cQRh2z3kGw==} + peerDependencies: + pg: ^8 + dependencies: + pg: 8.23.0 + dev: false + + /pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + dev: false + + /pg-pool@3.14.0(pg@8.23.0): + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + dependencies: + pg: 8.23.0 + dev: false + + /pg-protocol@1.16.0: + resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==} + dev: false + + /pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + dev: false + + /pg@8.23.0: + resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + dependencies: + pg-connection-string: 2.14.0 + pg-pool: 3.14.0(pg@8.23.0) + pg-protocol: 1.16.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + dev: false + + /pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + dependencies: + split2: 4.2.0 + dev: false + /picocolors@1.0.1: resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==} dev: true @@ -6547,6 +7114,43 @@ packages: picocolors: 1.1.1 source-map-js: 1.2.1 + /postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + dev: false + + /postgres-array@3.0.4: + resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==} + engines: {node: '>=12'} + dev: false + + /postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + dev: false + + /postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + dev: false + + /postgres-date@2.1.0: + resolution: {integrity: sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==} + engines: {node: '>=12'} + dev: false + + /postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + dependencies: + xtend: 4.0.2 + dev: false + + /postgres-interval@4.1.0: + resolution: {integrity: sha512-nJLACcOHi2iyRuMJeW6CuIA0Dc0i1YEpRr26XDyHAfxPjYUJOFxwZjmOc5oFFctTTh/OlRAMlnlXEJvr170fJg==} + engines: {node: '>=12'} + dev: false + /postgres@3.4.7: resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} engines: {node: '>=12'} @@ -6946,6 +7550,10 @@ packages: /rou3@0.7.12: resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} + /rou3@0.9.2: + resolution: {integrity: sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==} + dev: false + /run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -6981,7 +7589,6 @@ packages: /safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - dev: true /scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -7094,6 +7701,21 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + /split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + dev: false + + /sql-escaper@1.5.2: + resolution: {integrity: sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==} + engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} + dev: false + + /sqlstring@2.3.3: + resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} + engines: {node: '>= 0.6'} + dev: false + /stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -7342,7 +7964,7 @@ packages: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} dev: true - /ts-node@10.9.2(@types/node@20.14.11)(typescript@5.5.3): + /ts-node@10.9.2(@types/node@20.14.11)(typescript@5.9.3): resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} hasBin: true peerDependencies: @@ -7368,7 +7990,7 @@ packages: create-require: 1.1.1 diff: 4.0.4 make-error: 1.3.6 - typescript: 5.5.3 + typescript: 5.9.3 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 dev: true @@ -7514,6 +8136,12 @@ packages: engines: {node: '>=14.17'} hasBin: true + /typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + dev: true + /uglify-js@3.19.3: resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} engines: {node: '>=0.8.0'} @@ -7829,6 +8457,11 @@ packages: is-wsl: 3.1.1 dev: true + /xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + dev: false + /yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -7867,6 +8500,9 @@ packages: /zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + /zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + /zxing-wasm@1.3.4: resolution: {integrity: sha512-9l0QymyATF19FmI92QHe7Dayb+BUN7P7zFAt5iDgTnUf0dFWokz6GVA/W9EepjW5q8s3e89fIE/7uxpX27yqEQ==} dependencies: From f4a6191756f2629d0f41f65ddd3f0c9d2f1db857 Mon Sep 17 00:00:00 2001 From: joshuasilva414 Date: Sun, 27 Sep 2026 13:28:56 -0500 Subject: [PATCH 02/14] Migrate HackKit to MikroORM and Core-owned Better Auth --- .github/workflows/prettier.yml | 13 +- .github/workflows/web.yml | 85 +- .prettierignore | 3 - CONTEXT.md | 69 +- README.md | 7 +- apps/web/README.md | 221 +- apps/web/app/api/admin/export/route.ts | 15 +- apps/web/app/api/auth/[...all]/route.ts | 11 +- apps/web/app/api/files/upload/route.ts | 2 +- apps/web/app/api/files/view/route.ts | 8 +- apps/web/app/hackkit-plugin-actions.ts | 72 +- .../onboarding/hacker/hacker-form-defaults.ts | 12 +- apps/web/better-auth.config.ts | 13 - .../db/__tests__/database-workflow.test.ts | 149 +- apps/web/db/__tests__/dialects.test.ts | 240 + .../db/__tests__/fixtures/app-migration.sql | 4 - apps/web/db/__tests__/fixtures/app-schema.ts | 6 - apps/web/db/migrations/0000_init.sql | 329 -- .../web/db/migrations/meta/0000_snapshot.json | 2299 -------- apps/web/db/migrations/meta/_journal.json | 13 - apps/web/db/migrations/mysql/.snapshot.json | 4619 ++++++++++++++++ .../mysql/Migration20260927180400_initial.ts | 214 + .../db/migrations/postgresql/.snapshot.json | 4586 ++++++++++++++++ .../Migration20260927180400_initial.ts | 210 + apps/web/db/migrations/sqlite/.snapshot.json | 4661 +++++++++++++++++ .../sqlite/Migration20260927180400_initial.ts | 145 + apps/web/db/schema/auth.ts | 107 - apps/web/db/schema/hackkit.ts | 262 - apps/web/drizzle.config.ts | 12 - apps/web/env.ts | 32 +- apps/web/hackkit.config.ts | 7 +- apps/web/lib/auth-options.ts | 29 + apps/web/lib/auth.ts | 52 +- apps/web/lib/database-config.ts | 59 + apps/web/lib/db.ts | 15 - apps/web/lib/runtime.ts | 14 +- apps/web/next.config.mjs | 16 +- apps/web/package.json | 29 +- apps/web/scripts/test-db-workflow.sh | 37 +- apps/web/scripts/test-runtime.mjs | 136 + ...ackkit-core-kernel-and-database-adapter.md | 2 + ...re-storage-schema-and-adapter-factories.md | 2 + ...-owned-schema-generation-and-migrations.md | 2 + docs/adr/0010-hackkit-logger.md | 36 +- docs/adr/0013-mikroorm-and-core-auth.md | 37 + docs/guides/next-integration.md | 105 +- package.json | 6 +- packages/auth-better-auth/.gitignore | 1 - packages/auth-better-auth/package.json | 37 - packages/auth-better-auth/src/index.ts | 60 - packages/auth-better-auth/src/session.ts | 18 - packages/auth-better-auth/tsconfig.json | 16 - packages/blob-local/package.json | 4 +- packages/blob-s3/package.json | 4 +- packages/cli/bin/hackkit.mjs | 2 + packages/cli/package.json | 15 +- packages/cli/src/__tests__/cli-config.test.ts | 6 +- .../cli/src/__tests__/db-migrations.test.ts | 72 + packages/cli/src/__tests__/db-schema.test.ts | 84 - packages/cli/src/__tests__/db-seed.test.ts | 55 - .../__tests__/plugin-actions-scope.test.ts | 38 + .../cli/src/__tests__/plugin-schema.test.ts | 30 +- packages/cli/src/cli.ts | 30 +- packages/cli/src/db-migrations.ts | 44 + packages/cli/src/db-schema.ts | 51 - packages/cli/src/db-seed.ts | 15 +- packages/cli/src/index.ts | 7 +- packages/cli/src/orm.ts | 22 + packages/cli/src/plugin-commands.ts | 19 +- packages/cli/src/plugin-sync.ts | 48 +- packages/cli/tsdown.config.ts | 1 - packages/config/package.json | 11 +- packages/config/src/__tests__/config.test.ts | 4 +- packages/config/src/index.ts | 16 +- packages/core/package.json | 8 + .../core/src/__tests__/admin-console.test.ts | 36 +- packages/core/src/__tests__/check-in.test.ts | 44 +- packages/core/src/__tests__/groups.test.ts | 47 +- packages/core/src/__tests__/logger.test.ts | 2 +- .../core/src/__tests__/notifications.test.ts | 82 +- .../core/src/__tests__/public-profile.test.ts | 31 +- .../src/__tests__/register-hacker.test.ts | 68 +- .../src/__tests__/resume-url-schema.test.ts | 2 +- packages/core/src/__tests__/rsvp.test.ts | 51 +- .../core/src/__tests__/seed-test-owner.ts | 25 +- packages/core/src/access-control.ts | 6 +- packages/core/src/adapters/auth.ts | 23 - packages/core/src/adapters/db/memory.ts | 137 - packages/core/src/client.ts | 11 + packages/core/src/database.ts | 308 -- packages/core/src/domain-log.ts | 10 +- packages/core/src/event-types.ts | 6 +- packages/core/src/functions/admin.ts | 83 +- packages/core/src/functions/events.ts | 146 +- packages/core/src/functions/groups.ts | 84 +- .../core/src/functions/registration-policy.ts | 50 +- packages/core/src/functions/rsvp.ts | 433 +- packages/core/src/functions/settings.ts | 178 +- packages/core/src/functions/users.ts | 544 +- packages/core/src/groups.ts | 21 +- packages/core/src/hackkit-context.ts | 25 +- packages/core/src/hackkit.ts | 460 +- packages/core/src/index.ts | 139 +- packages/core/src/mikro/auth-entities.test.ts | 81 +- packages/core/src/mikro/auth-entities.ts | 37 +- .../core/src/mikro/auth-lifecycle.test.ts | 112 + packages/core/src/mikro/auth-schema.test.ts | 34 + packages/core/src/mikro/auth.ts | 64 +- .../src/mikro/domain-transactions.test.ts | 75 + .../core/src/mikro/entity-registry.test.ts | 48 + packages/core/src/mikro/operation.ts | 46 + packages/core/src/mikro/profile.ts | 24 +- packages/core/src/mikro/runtime.test.ts | 44 + packages/core/src/mikro/runtime.ts | 123 + packages/core/src/mikro/user.ts | 26 + packages/core/src/models.ts | 590 ++- packages/core/src/notifications.ts | 154 +- packages/core/src/permissions.ts | 2 +- packages/core/src/plugins.ts | 81 +- packages/core/src/schemas.ts | 10 +- packages/core/src/seed.ts | 32 +- packages/core/src/settings.ts | 37 +- packages/core/src/testing.ts | 71 + packages/core/src/types.ts | 47 +- packages/core/src/user-data-options.ts | 10 +- packages/core/tsconfig.json | 13 +- packages/db-drizzle/package.json | 47 - .../src/__tests__/libsql-adapter.test.ts | 51 - .../src/__tests__/postgres-adapter.test.ts | 96 - .../src/__tests__/schema-generation.test.ts | 249 - packages/db-drizzle/src/index.ts | 1 - packages/db-drizzle/src/libsql.ts | 6 - packages/db-drizzle/src/postgres.ts | 69 - packages/db-drizzle/src/runtime.ts | 137 - packages/db-drizzle/src/schema.ts | 320 -- packages/db-drizzle/src/sqlite.ts | 61 - .../test-fixtures/postgres/drizzle.config.ts | 12 - .../postgres/migrations/0000_init.sql | 7 - .../migrations/meta/0000_snapshot.json | 62 - .../postgres/migrations/meta/_journal.json | 13 - .../test-fixtures/postgres/schema.ts | 9 - packages/db-drizzle/tsconfig.json | 16 - packages/next/README.md | 121 +- packages/next/package.json | 10 +- .../next/src/__tests__/page-guards.test.ts | 71 +- packages/next/src/__tests__/runtime.test.ts | 66 + packages/next/src/index.ts | 1 + packages/next/src/mutations.ts | 50 +- packages/next/src/runtime.ts | 130 +- packages/plugin-discord/package.json | 14 +- .../src/__tests__/discord.test.ts | 74 +- packages/plugin-discord/src/actions.ts | 6 +- packages/plugin-discord/src/api.ts | 261 +- packages/plugin-discord/src/index.ts | 24 +- packages/plugin-discord/src/models.ts | 167 +- packages/plugin-discord/src/providers.ts | 13 +- .../plugin-notifications-email/package.json | 6 +- .../src/__tests__/email-notifications.test.ts | 41 +- .../plugin-notifications-email/src/api.ts | 13 +- .../plugin-notifications-email/src/index.ts | 13 +- .../src/providers.ts | 36 +- packages/plugin-teams/package.json | 14 +- .../plugin-teams/src/__tests__/teams.test.ts | 135 +- packages/plugin-teams/src/actions.ts | 2 +- packages/plugin-teams/src/api.ts | 402 +- .../src/components/team-dashboard.tsx | 83 +- packages/plugin-teams/src/index.ts | 33 +- packages/plugin-teams/src/models.ts | 143 +- packages/ui/package.json | 2 +- packages/ui/src/actions.ts | 2 +- .../src/components/admin-console-controls.tsx | 146 +- packages/ui/src/components/admin-console.tsx | 232 +- .../ui/src/components/check-in-scanner.tsx | 12 +- .../ui/src/components/event-admin-list.tsx | 6 +- packages/ui/src/components/event-pass.tsx | 6 +- packages/ui/src/components/event-scanner.tsx | 15 +- packages/ui/src/components/hack-tag-form.tsx | 2 +- .../components/hackathon-settings-form.tsx | 5 +- .../components/hacker-registration-form.tsx | 2 +- .../ui/src/components/public-profile-card.tsx | 15 +- packages/ui/src/components/public-shell.tsx | 100 +- packages/ui/src/components/schedule-list.tsx | 10 +- packages/ui/src/components/user-data-form.tsx | 2 +- .../components/user-profile-settings-form.tsx | 6 +- packages/ui/src/event-pass.ts | 24 +- packages/ui/src/lib/admin-console.tsx | 2 +- packages/ui/src/lib/onboarding-steps.ts | 6 +- packages/ui/src/types.ts | 25 +- packages/ui/src/user-data-form-defaults.ts | 4 +- pnpm-lock.yaml | 2105 +------- 190 files changed, 20108 insertions(+), 10240 deletions(-) delete mode 100644 apps/web/better-auth.config.ts create mode 100644 apps/web/db/__tests__/dialects.test.ts delete mode 100644 apps/web/db/__tests__/fixtures/app-migration.sql delete mode 100644 apps/web/db/__tests__/fixtures/app-schema.ts delete mode 100644 apps/web/db/migrations/0000_init.sql delete mode 100644 apps/web/db/migrations/meta/0000_snapshot.json delete mode 100644 apps/web/db/migrations/meta/_journal.json create mode 100644 apps/web/db/migrations/mysql/.snapshot.json create mode 100644 apps/web/db/migrations/mysql/Migration20260927180400_initial.ts create mode 100644 apps/web/db/migrations/postgresql/.snapshot.json create mode 100644 apps/web/db/migrations/postgresql/Migration20260927180400_initial.ts create mode 100644 apps/web/db/migrations/sqlite/.snapshot.json create mode 100644 apps/web/db/migrations/sqlite/Migration20260927180400_initial.ts delete mode 100644 apps/web/db/schema/auth.ts delete mode 100644 apps/web/db/schema/hackkit.ts delete mode 100644 apps/web/drizzle.config.ts create mode 100644 apps/web/lib/auth-options.ts create mode 100644 apps/web/lib/database-config.ts delete mode 100644 apps/web/lib/db.ts create mode 100644 apps/web/scripts/test-runtime.mjs create mode 100644 docs/adr/0013-mikroorm-and-core-auth.md delete mode 100644 packages/auth-better-auth/.gitignore delete mode 100644 packages/auth-better-auth/package.json delete mode 100644 packages/auth-better-auth/src/index.ts delete mode 100644 packages/auth-better-auth/src/session.ts delete mode 100644 packages/auth-better-auth/tsconfig.json create mode 100755 packages/cli/bin/hackkit.mjs create mode 100644 packages/cli/src/__tests__/db-migrations.test.ts delete mode 100644 packages/cli/src/__tests__/db-schema.test.ts delete mode 100644 packages/cli/src/__tests__/db-seed.test.ts create mode 100644 packages/cli/src/__tests__/plugin-actions-scope.test.ts create mode 100644 packages/cli/src/db-migrations.ts delete mode 100644 packages/cli/src/db-schema.ts create mode 100644 packages/cli/src/orm.ts delete mode 100644 packages/core/src/adapters/auth.ts delete mode 100644 packages/core/src/adapters/db/memory.ts create mode 100644 packages/core/src/client.ts delete mode 100644 packages/core/src/database.ts create mode 100644 packages/core/src/mikro/auth-lifecycle.test.ts create mode 100644 packages/core/src/mikro/auth-schema.test.ts create mode 100644 packages/core/src/mikro/domain-transactions.test.ts create mode 100644 packages/core/src/mikro/entity-registry.test.ts create mode 100644 packages/core/src/mikro/operation.ts create mode 100644 packages/core/src/mikro/runtime.test.ts create mode 100644 packages/core/src/mikro/runtime.ts create mode 100644 packages/core/src/mikro/user.ts create mode 100644 packages/core/src/testing.ts delete mode 100644 packages/db-drizzle/package.json delete mode 100644 packages/db-drizzle/src/__tests__/libsql-adapter.test.ts delete mode 100644 packages/db-drizzle/src/__tests__/postgres-adapter.test.ts delete mode 100644 packages/db-drizzle/src/__tests__/schema-generation.test.ts delete mode 100644 packages/db-drizzle/src/index.ts delete mode 100644 packages/db-drizzle/src/libsql.ts delete mode 100644 packages/db-drizzle/src/postgres.ts delete mode 100644 packages/db-drizzle/src/runtime.ts delete mode 100644 packages/db-drizzle/src/schema.ts delete mode 100644 packages/db-drizzle/src/sqlite.ts delete mode 100644 packages/db-drizzle/test-fixtures/postgres/drizzle.config.ts delete mode 100644 packages/db-drizzle/test-fixtures/postgres/migrations/0000_init.sql delete mode 100644 packages/db-drizzle/test-fixtures/postgres/migrations/meta/0000_snapshot.json delete mode 100644 packages/db-drizzle/test-fixtures/postgres/migrations/meta/_journal.json delete mode 100644 packages/db-drizzle/test-fixtures/postgres/schema.ts delete mode 100644 packages/db-drizzle/tsconfig.json create mode 100644 packages/next/src/__tests__/runtime.test.ts diff --git a/.github/workflows/prettier.yml b/.github/workflows/prettier.yml index c0c6c8c0..728a01d5 100644 --- a/.github/workflows/prettier.yml +++ b/.github/workflows/prettier.yml @@ -10,19 +10,20 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22.17.0" - name: Install pnpm - run: | - npm install -g pnpm + uses: pnpm/action-setup@v4 + with: + version: 8.3.1 - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile - name: Run Prettier check run: pnpm run format-check diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 664770b6..144d9af1 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -28,6 +28,26 @@ jobs: --health-timeout 5s --health-retries 5 + mysql: + image: mysql:8.4 + env: + MYSQL_ROOT_PASSWORD: mysql + MYSQL_DATABASE: hackkit + ports: + - 3306:3306 + options: >- + --health-cmd "mysqladmin ping -h 127.0.0.1 -pmysql" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + libsql: + image: ghcr.io/tursodatabase/libsql-server:latest + env: + SQLD_NODE: standalone + SQLD_HTTP_LISTEN_ADDR: 0.0.0.0:8080 + ports: + - 8080:8080 + steps: - name: Checkout code uses: actions/checkout@v4 @@ -53,56 +73,27 @@ jobs: working-directory: apps/web run: pnpm plugin:sync - - name: Regenerate native schema files - working-directory: apps/web - run: | - pnpm schema:generate - pnpm auth:schema + - name: Verify generated plugin files are current + run: git diff --exit-code -- apps/web/app apps/web/hackkit.lock - - name: Verify generated files are current - run: | - changed="$(git status --porcelain -- apps/web/app/discord apps/web/app/invites/page.tsx apps/web/app/teams/page.tsx apps/web/app/hackkit-plugin-actions.ts apps/web/db/schema apps/web/hackkit.lock)" - if [ -n "$changed" ]; then - echo "$changed" - git diff -- apps/web/app/discord apps/web/app/invites/page.tsx apps/web/app/teams/page.tsx apps/web/app/hackkit-plugin-actions.ts apps/web/db/schema apps/web/hackkit.lock - exit 1 - fi - - - name: Validate and apply migrations - working-directory: apps/web + - name: Wait for network libSQL + run: curl --fail --retry 20 --retry-connrefused --retry-delay 2 http://127.0.0.1:8080/health + + - name: Test all SQL drivers against committed migrations env: - DATABASE_URL: file:.data/ci.db - run: | - mkdir -p .data - pnpm drizzle-kit check - pnpm db:generate - changed="$(git status --porcelain -- db/migrations)" - if [ -n "$changed" ]; then - echo "$changed" - git diff -- db/migrations - exit 1 - fi - pnpm db:migrate + HACKKIT_REQUIRE_DATABASE_MATRIX: "1" + HACKKIT_TEST_POSTGRESQL_URL: postgresql://postgres:postgres@127.0.0.1:5432/hackkit + HACKKIT_TEST_MYSQL_URL: mysql://root:mysql@127.0.0.1:3306/hackkit + HACKKIT_TEST_LIBSQL_URL: http://127.0.0.1:8080 + run: pnpm --filter web test:db-matrix - name: Test database workflow isolation run: pnpm --filter web test:db-workflow - - name: Validate PostgreSQL migration fixture - run: | - pnpm --filter @hackkit/db-drizzle exec drizzle-kit check --config test-fixtures/postgres/drizzle.config.ts - pnpm --filter @hackkit/db-drizzle exec drizzle-kit generate --config test-fixtures/postgres/drizzle.config.ts - changed="$(git status --porcelain -- packages/db-drizzle/test-fixtures/postgres)" - if [ -n "$changed" ]; then - echo "$changed" - git diff -- packages/db-drizzle/test-fixtures/postgres - exit 1 - fi - - name: Package tests run: | pnpm --filter web test pnpm --filter @hackkit/core test - POSTGRES_TEST_URL=postgres://postgres:postgres@localhost:5432/hackkit pnpm --filter @hackkit/db-drizzle test pnpm --filter @hackkit/cli test pnpm --filter @hackkit/config test pnpm --filter @hackkit/next test @@ -110,13 +101,20 @@ jobs: pnpm --filter @hackkit/plugin-discord test pnpm --filter @hackkit/plugin-notifications-email test + - name: Prepare isolated build database + env: + DATABASE_URL: file:.data/ci.db + run: | + mkdir -p apps/web/.data + pnpm --filter web db:migrate + pnpm --filter web db:seed + - name: Typecheck run: pnpm --filter web typecheck - name: Build web env: - DATABASE_URL: libsql://web-ci.turso.io - TURSO_AUTH_TOKEN: ci-token + DATABASE_URL: file:.data/ci.db NEXT_PUBLIC_APP_URL: https://web-ci.example.com BETTER_AUTH_URL: https://web-ci.example.com BETTER_AUTH_SECRET: ci-web-secret-that-is-at-least-32-characters @@ -131,3 +129,6 @@ jobs: DISCORD_INTERNAL_AUTH_KEY: ci-internal-auth-key DISCORD_PARTICIPANT_ROLE_ID: ci-participant-role run: pnpm --filter web build + + - name: Test production HTTP runtime + run: pnpm --filter web test:runtime diff --git a/.prettierignore b/.prettierignore index ab1ec330..b2940643 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,8 +1,5 @@ -packages/db/drizzle/meta/** pnpm-lock.yaml pnpm-workspace.yaml **/package.json *.json -apps/web/db/schema/** apps/web/db/migrations/** -packages/db-drizzle/test-fixtures/** diff --git a/CONTEXT.md b/CONTEXT.md index 93c03fab..923f834a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -17,7 +17,7 @@ A replaceable component package that renders HackKit interfaces by consuming Hac _Avoid_: Web app, core **HackKit CLI**: -A command-line tool for creating HackKit projects, managing plugin-provided project files, generating adapter-native schema, and explicitly seeding configured role data. +A command-line tool for creating HackKit projects, managing plugin-provided project files, generating and explicitly applying app-owned MikroORM migrations, and explicitly seeding configured role data. _Avoid_: Runtime, core **HackKit Plugin**: @@ -25,28 +25,21 @@ An extension passed to HackKit Core at creation time that may add capabilities, _Avoid_: App fork, core patch **Auth ID**: -The provider-issued identifier for the authenticated person using a HackKit application. +The Better Auth user identifier for the authenticated person using a HackKit application. _Avoid_: Clerk ID, internal user ID, auth subject -**Auth Adapter**: -An integration boundary that translates an authentication provider's session and profile into HackKit's Auth ID and User identity fields. -_Avoid_: HackKit Core auth system, session store +**Core Authentication**: +Better Auth constructed by Core, with app-supplied secrets, providers, origins, and options. Next owns HTTP and cookie integration. -**Database Adapter**: -An integration boundary that persists HackKit's canonical data models using a concrete database technology. -_Avoid_: App schema mapper, repository bundle +**MikroORM Entity**: +A native `defineEntity` definition shared by runtime and migrations. Core, plugins, and apps contribute entities before ORM initialization. -**Storage Schema**: -An adapter-neutral description of HackKit model fields, constraints, and model relationships that database adapters compile into concrete storage schemas. -_Avoid_: Drizzle schema, SQL migration, app-owned table mapping - -**Drizzle Database Adapter**: -A Database Adapter that persists HackKit's canonical data models through Drizzle ORM. -_Avoid_: Existing app database package, app schema mapper +**Request Scope**: +A fresh EntityManager and domain API set associated with one request, action, job, or CLI operation. Connections are shared; managed entity state is not. **HackKit Logger**: A replaceable logging boundary for HackKit runtime diagnostics and operational messages, configured when creating **HackKit Core** (similar in spirit to Better Auth’s built-in logger: levels, disable switch, optional custom `log` implementation). -_Avoid_: Legacy `error_log` tables, **Audit Log**, **Auth Adapter** session storage +_Avoid_: Legacy `error_log` tables, **Audit Log**, authentication session storage **Log Level**: The minimum severity a **HackKit Logger** emits: `debug`, `info`, `warn`, or `error`. @@ -247,49 +240,29 @@ _Avoid_: **Organiser Approval** queue, **Hackathon Capacity**, automatic accepta - **HackKit CLI** runs from a **HackKit Web App** project directory (not a monorepo root) and reads that app’s `hackkit.config.ts` for plugins and database settings. - **HackKit CLI** uses the same **HackKit Logger** from `hackkit.config.ts` when run from a **HackKit Web App** directory, falling back to the environment-based default **Log Level** when `logger` is omitted. - **HackKit CLI** may explicitly seed configured roles into the app database, but it does not assign those roles to users. -- **HackKit CLI** may generate a concrete adapter-native schema file from merged **Storage Schema**, but it does not generate, apply, inspect, baseline, or roll back database migrations. +- **HackKit CLI** explicitly generates or applies app-owned MikroORM migrations from the same entity collection as runtime. Plugin sync and application startup never apply migrations. - A **HackKit Plugin** may add capabilities to a **HackKit Web App** without changing **HackKit Core** source. - **HackKit Core** accepts an optional **HackKit Logger** at creation time; when omitted, a default console **HackKit Logger** applies with a configurable **Log Level** defaulting to `info` in development and `warn` in production unless overridden. - **HackKit Web Apps** may replace the default **HackKit Logger** with a custom implementation (for example forwarding to a hosted logging service) without changing **HackKit Core** source. - **HackKit Core** emits operational messages for significant domain APIs (such as **Hacker Registration**, **Organiser Approval**, **Hackathon Check-in**) through the **HackKit Logger** at **Log Level**s such as `info` or `debug`; this is not a separate persisted **Audit Log** model in v1. - **Log Context** for those messages includes action name, outcome, **Auth ID**s, relevant record ids, and error codes on failure; it excludes PII and form payloads. - **Hackathon Setting** changes are emitted through the **HackKit Logger** with **Log Context** including the actor **Auth ID** and setting key, but v1 stores only the latest setting value rather than a persisted change history. -- **HackKit Core** receives **HackKit Plugins** through `createHackkit`. +- **HackKit Core** receives **HackKit Plugins** through `initializeHackkit`. - **HackKit Plugins** should be configured in one place so package-specific integration details stay contained inside plugin packages. -- **HackKit Plugins** expose storage schema contributions using **Storage Schema** without requiring every plugin to implement every database dialect. -- **Storage Schema** can express field-level single-column references between plugin models and **HackKit Core** models. +- **HackKit Plugins** contribute native MikroORM entities with namespaced table names and real relations to Core entities. - **HackKit Core** owns **Notification Intents** and typed notification payloads; **Notification Channel Plugins** own channel-specific destination resolution, rendering, delivery, and **Notification Delivery Attempts**. - **Notification Intents** are persisted before delivery so channel plugins may process them asynchronously and retry failed **Notification Delivery Attempts**. - The **Email Notification Plugin** is configured as a **HackKit Plugin**; **HackKit Core** does not send email directly. -- **Storage Schema** uses domain property names; database adapters map them to concrete column names. -- Database adapters derive table names from namespaced model keys using deterministic naming conventions. -- SQLite table names use a `hackkit_` prefix followed by namespace and snake-cased model name. -- Database adapters map **Storage Schema** field names to snake_case column names by default. -- Database adapters translate query inputs and returned records so **HackKit Core** only sees domain property names. -- **Storage Schema** supports static default values and symbolic dynamic defaults such as creation timestamps and generated string IDs. -- Database adapters apply **Storage Schema** defaults before inserting records to keep behavior consistent across dialects. -- **HackKit Core** may still set domain timestamps and IDs explicitly in application behavior. -- **Storage Schema** distinguishes integer fields from general numeric fields. -- **Storage Schema** supports string enum fields for constrained domain values. -- **Storage Schema** represents arrays and structured objects as JSON fields. -- **Storage Schema** supports field-level and composite uniqueness constraints. -- **Storage Schema** supports simple single-field and composite non-unique indexes. -- Persistent model descriptors combine typed model identity with **Storage Schema** so model keys and storage definitions do not drift. -- Persistent model record types are inferred from **Storage Schema** definitions. -- **Storage Schema** distinguishes selected record types from insert input types so defaulted fields can be optional on insert and present after persistence. -- Database adapters accept insert input types and return selected record types derived from persistent model descriptors. -- Public Core record types such as **User**, **Role**, and **Hacker** are inferred aliases from Core model descriptors. -- **HackKit Core** merges its base **Storage Schema** with **HackKit Plugin** storage schemas before initializing adapter factories. -- **HackKit Core** initializes adapter factories with plugin contributions so applications do not pass plugin config to each adapter separately. -- An **Auth Adapter** supplies **Auth IDs** and User identity fields to a **HackKit Web App** before it calls **HackKit Core**. +- Core, auth, enabled plugin, and app entities are collected before initialization. Duplicate names and tables fail initialization. +- Native entity metadata defines constraints, relations, defaults, and inferred DTO types. SQL column names use snake_case across dialects. +- Core and server plugins query a scoped EntityManager directly. Domain operations retain authorization and validation. +- Better Auth owns identity and email. The linked HackKit profile owns editable profile and domain state; signup creates both atomically, and session reads never overwrite the profile. +- Each request, action, job, and CLI operation gets a fresh identity map. Public domain results are plain DTOs. - The **Better Auth** integration may forward **HackKit Logger** messages through Better Auth’s own `logger` configuration so a single custom `log` implementation in `hackkit.config.ts` covers auth and **HackKit Core** output. - A **User Data** onboarding flow may display the authenticated **User** while collecting **User Data**. - **HackKit UI** treats authenticated **User** information passed to forms as display context, not as authorization input. -- A **Database Adapter** persists **HackKit Core** models using HackKit-owned canonical storage shapes. -- The **Drizzle Database Adapter** supports SQLite/libSQL and PostgreSQL through dialect-specific entrypoints. -- The **Drizzle Database Adapter** exposes dialect-specific entrypoints so each dialect can use native Drizzle schema definitions. -- The **Drizzle Database Adapter** generates concrete Drizzle schema from merged **Storage Schema** while Drizzle Kit remains the application's migration tool. -- The **HackKit Web App** commits separately generated HackKit and Better Auth schema files and delegates migration generation and application entirely to Drizzle Kit. +- **HackKit Web Apps** commit reviewed MikroORM migrations and snapshots for PostgreSQL, MySQL, and SQLite/libSQL. Generation, application, and seeding are explicit release steps. +- This redesign targets fresh databases; deployed data and existing accounts require a separately designed migration. - A **Blob Storage Adapter** stores files outside **HackKit Core**; **HackKit Core** stores only **Stored File References** supplied by the **HackKit Web App**. - **HackKit Web Apps** choose a **Blob Storage Adapter** implementation via configuration; local filesystem adapters suit development, S3-compatible adapters suit production. - A **User** is identified by exactly one **Auth ID** in a HackKit application. @@ -313,7 +286,7 @@ _Avoid_: **Organiser Approval** queue, **Hackathon Capacity**, automatic accepta - **Hackathon Capacity**, not **Group** membership, controls how many **Hackers** may receive **Organiser Approval**. - Organizers and judges are **Users** with **Roles**, not **Hackers**, unless they are also competing. - A **User** has one **Role** in v1. -- Completing **Hacker Registration** assigns the hackathon’s default competitor **Role** from `hackkit.config.ts`; auth sign-up and `ensureUser` do not assign a **Role** by themselves. +- Completing **Hacker Registration** assigns the hackathon’s default competitor **Role** from `hackkit.config.ts`; auth sign-up do not assign a **Role** by themselves. - A **Role** grants zero or more **Permissions**. - **Permissions** use namespaced keys so plugins can add capabilities without collisions. - **Admin Permission** is represented by `core.admin`. @@ -394,6 +367,6 @@ _Avoid_: **Organiser Approval** queue, **Hackathon Capacity**, automatic accepta - Admin experiences should surface when the current **Hacker** or approved **Hacker** count exceeds the configured **Maximum Registrations** or **Hackathon Capacity**. - Resume on **Hacker Registration** — resolved: optional `resumeUrl` only; no legacy “no resume provided” sentinel URL. - **Hacker** `group` assignment — resolved: **Group** is a HackKit participant cohort assigned at **Organiser Approval** time by round-robin distribution unless an organizer has already assigned one; it is separate from **Team** and may map to Discord roles through a plugin. -- Default competitor **Role** on onboarding — resolved: applied when **Hacker Registration** completes, not at auth/`ensureUser` (legacy-aligned). +- Default competitor **Role** on onboarding — resolved: applied when **Hacker Registration** completes, not at auth signup (legacy-aligned). - **Hacker** vs **User** for **Event Pass** could mean competitors only — resolved: attendance uses **Auth ID** for any **User**, matching the original HackKit convention that all participants share one identity record. - Legacy storage used one scan row per user and event with an incrementing count — resolved: HackKit Core stores each scan as a separate **Event Scan** row with its own identifier. diff --git a/README.md b/README.md index 1e62cc23..cf74a5da 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,14 @@ - TypeScript - Next.js -- Drizzle ORM +- MikroORM +- Better Auth - Tailwind CSS +## Development + +See [the reference app setup](apps/web/README.md) and [the persistence decision](docs/adr/0013-mikroorm-and-core-auth.md). This migration targets fresh databases. + ## Join The Team Want to help us build HackKit? Please reach out to us at `tech@acmutsa.org` or join us on [Discord](https://go.acmutsa.org/projectsdiscord)! diff --git a/apps/web/README.md b/apps/web/README.md index e53d549d..fd366206 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -1,199 +1,84 @@ -# web +# HackKit reference web app -Production-ready **HackKit Web App** for Better Auth, libSQL/Turso, HackKit UI flows, Teams, RSVP, Discord verification, and notifications. +Next.js app using MikroORM, Core-owned Better Auth, HackKit UI, Teams, Discord verification, and notifications. This migration targets a fresh database. Existing databases and user accounts are not converted. ## Local setup -Run every command below from the repository root. +Use Node.js 22.17 or newer and pnpm 8.3.1. Run these commands from the repository root: -### 1. Install prerequisites and dependencies +1. Install dependencies with `pnpm install`. +2. Build workspace packages with `pnpm exec turbo run build --filter=web^...`. +3. Sync plugin routes and actions with `pnpm --filter web sync`. +4. Apply committed migrations with `pnpm --filter web db:migrate`. +5. Insert configured roles with `pnpm --filter web db:seed`. +6. Start Next.js with `pnpm --filter web dev`. -- Node.js 20.x -- pnpm 8.3.1 +Development defaults use `http://localhost:3000`, SQLite at `apps/web/.data/web.db`, and local blob storage. OAuth, email, and Discord providers are optional locally. The runtime does not migrate or seed on startup. Plugin sync only updates routes, actions, and `hackkit.lock`. -The repository enforces pnpm through `preinstall`. +After signup, bootstrap an owner explicitly by assigning `core.owner` to that user's `role_id` in `core_user`, selected by `auth_id`. Better Auth's `user.id` is the canonical ID. The runtime does not infer ownership from email. -```bash -pnpm install -``` +`pnpm --filter web db:reset` discards only the selected `file:` database and its WAL/SHM files, then migrates and seeds. It refuses remote URLs. Never use it for data you need to retain. -### 2. Build the workspace dependencies +## Database selection -Build the packages that `web` imports before running commands that use the `hackkit` CLI: +| Driver | DATABASE_URL | HACKKIT_DATABASE_DIALECT | +| ---------------------- | ------------------------------------------ | ---------------------------------- | +| SQLite | `file:.data/web.db` | `sqlite` (default for local paths) | +| local libSQL | `file:.data/web.db` | `libsql` (explicit) | +| network libSQL / Turso | `libsql://...` or `https://...` | `libsql` (inferred) | +| PostgreSQL | `postgresql://user:password@host/database` | `postgresql` (inferred) | +| MySQL | `mysql://user:password@host/database` | `mysql` (inferred) | -```bash -pnpm exec turbo run build --filter=web^... -``` +Set `TURSO_AUTH_TOKEN` when the libSQL endpoint requires authentication. Driver options live in [lib/database-config.ts](lib/database-config.ts). -This builds the CLI, database adapter, and other workspace packages that the app imports. `dev` repeats this dependency build. +## Entity and migration workflow -### 3. Configure local integrations (optional) +Core, Better Auth, enabled plugins, and application entities form one native MikroORM registry. Add app-owned `defineEntity` definitions through `entities` in `hackkit.config.ts`; plugins expose an `entities` array. Runtime and CLI use the same registry. Auth plugins requiring extra tables or fields must declare them before initialization; undeclared schema fails early. -No environment file is required for a basic local run. Development defaults use: +Migrations and snapshots are committed under `db/migrations/postgresql`, `db/migrations/mysql`, and `db/migrations/sqlite`. libSQL uses the SQLite chain. After changing entities: -- `http://localhost:3000` for the app and Better Auth URLs -- `file:.data/web.db` for the database -- local storage in `.data/uploads` -- no email provider and no Discord bot role-sync provider +1. Select a disposable development database for the dialect using the variables above. +2. Run `pnpm --filter web db:generate --name describe-change`. +3. Review the generated TypeScript migration and snapshot. Repeat generation for the other dialects. +4. Apply with `pnpm --filter web db:migrate` and validate against fresh databases. +5. Commit every dialect's reviewed migration and snapshot. -OAuth, email delivery, S3 storage, Turso, and Discord bot role sync are optional locally; configure them only when testing those integrations. +Removing a plugin can generate table drops. Review data retention before applying them. `db:seed` only inserts missing configured role IDs; it does not update existing roles or assign them to users. -### 4. Generate app-owned files and apply migrations +## Authentication and request scope -Sync plugin-owned project files, apply the committed migration chain, then add the roles declared in `hackkit.config.ts`: +[lib/auth-options.ts](lib/auth-options.ts) supplies secrets, origins, and OAuth providers. Core owns Better Auth construction, native entities, the pinned community adapter, and atomic profile creation. Auth owns email; profile edits are stored separately and survive later session reads. -```bash -pnpm --filter web sync -pnpm --filter web db:migrate -pnpm --filter web db:seed -``` +[lib/runtime.ts](lib/runtime.ts) initializes shared connections and registers a function that creates request-scoped domain APIs. Next owns cookies, headers, guards, and redirects. Server actions return plain DTOs. Browser code uses `@hackkit/core/client`; it does not import the ORM. -`sync` only updates plugin routes, actions, and `hackkit.lock`. It does not generate database schema or connect to a database. +## Production configuration -`db:seed` is idempotent: it inserts configured roles that do not already exist and skips existing role IDs. The application runtime never seeds roles during startup or page requests. +Set explicit `DATABASE_URL`, `NEXT_PUBLIC_APP_URL`, `BETTER_AUTH_URL`, and `BETTER_AUTH_SECRET` (at least 32 characters). Configure `BETTER_AUTH_TRUSTED_ORIGINS` as a comma-separated list when needed. The app's production checks also require: -Use `db:reset` when you want to discard and recreate the configured local file database: +- `HACKKIT_BLOB_ADAPTER=s3`, bucket, region, access key, and secret key; set `HACKKIT_S3_ENDPOINT` for compatible stores. +- `DISCORD_GUILD_ID`, `DISCORD_BOT_API_URL`, `DISCORD_INTERNAL_AUTH_KEY`, and `DISCORD_PARTICIPANT_ROLE_ID` (or role name). +- Optional email through `HACKKIT_EMAIL_PROVIDER=resend` plus `RESEND_API_KEY`, or `smtp` plus SMTP settings. -```bash -pnpm --filter web db:reset -``` +See [env.ts](env.ts) for exact variable names. Database storage must persist across application restarts. Apply migrations and seed as explicit release steps. Builds must have valid configuration but do not apply schema changes. -`db:reset` refuses remote URLs, removes the selected local database and its WAL/SHM files, applies committed migrations, and runs `db:seed`. +## Validation -### 5. Start the app +Build dependencies before tests. Run `pnpm --filter web test` for the committed SQLite migration and auth flow. `pnpm --filter web test:db-workflow` verifies that sync does not connect and reset affects only its selected file. `pnpm --filter web verify` syncs generated files, typechecks, and builds Next.js. After building, `pnpm --filter web test:runtime` starts the production server on loopback port 33017 with an isolated temporary SQLite database, verifies HTTP auth and access control, then stops the server and removes its test data. -```bash -pnpm --filter web dev -``` +`pnpm --filter web test:db-matrix` always runs SQLite and local libSQL. For the complete matrix, supply URLs for **empty, disposable** databases: -Open [http://localhost:3000](http://localhost:3000) and sign up. To bootstrap the first owner for now, edit that user's `roleId` directly in `core_user`: +- `HACKKIT_TEST_POSTGRESQL_URL` +- `HACKKIT_TEST_MYSQL_URL` +- `HACKKIT_TEST_LIBSQL_URL` (a real HTTP/libSQL server) +- `HACKKIT_REQUIRE_DATABASE_MATRIX=1` to fail if any URL is missing. -```sql -UPDATE core_user -SET roleId = 'core.owner' -WHERE authId = ''; -``` +The matrix writes test accounts and domain records; it never drops the target database. Use fresh databases for every run. CI requires all five targets, checks schema agreement, runs package tests, typechecks, and builds Next.js. External OAuth, hosted Turso credentials/TLS, and real email/Discord delivery require deployment-specific validation. -The runtime does not infer ownership from an email address or auth ID. +## Architecture -### 6. Optional local checks +- [Next integration guide](../../docs/guides/next-integration.md) +- [MikroORM and Core auth decision](../../docs/adr/0013-mikroorm-and-core-auth.md) +- [hackkit.config.ts](hackkit.config.ts): plugins, database, auth, settings, and seed roles. +- [db/migrations](db/migrations): reviewed app-owned migrations. -Run the full app verification path before handing off a change: - -```bash -pnpm --filter web verify -``` - -`verify` runs: - -```bash -pnpm sync -pnpm typecheck -pnpm build -``` - -From the repo root, those map to the filtered `web` scripts. `typecheck` also builds `@hackkit/cli` first because the `hackkit` binary is needed by app scripts. - -## Schema and migration workflow - -HackKit, Better Auth, and application schemas are separate files under `db/schema`. Regenerate only the owner that changed: - -```bash -pnpm --filter web schema:generate # Core or plugin storage changed -pnpm --filter web auth:schema # Better Auth config/plugins changed -``` - -Both generated files are committed. After reviewing their diffs, let Drizzle generate and apply the migration: - -```bash -pnpm --filter web db:generate --name=describe-change -pnpm --filter web db:migrate -``` - -Review and commit the generated SQL and metadata. HackKit never generates or applies these migrations. `dev` does not inspect or initialize the database; a missing migration surfaces through the first database operation. - -## Build dependency graph - -Build every workspace package that web transitively requires before -building the app: - -```bash -pnpm exec turbo run build --filter=web^... -pnpm --filter web build -``` - -Turbo derives that dependency set from `apps/web/package.json`; add every -runtime or build-time workspace import there. CI runs the same graph command -before its production-configured web build. - -## Production setup - -Production requires remote persistence and explicit auth, storage, and Discord configuration: - -```bash -DATABASE_URL=libsql://... -TURSO_AUTH_TOKEN=... -NEXT_PUBLIC_APP_URL=https://your-app.example.com -BETTER_AUTH_URL=https://your-app.example.com -BETTER_AUTH_SECRET=at-least-32-characters -BETTER_AUTH_TRUSTED_ORIGINS=https://your-app.example.com -HACKKIT_BLOB_ADAPTER=s3 -HACKKIT_S3_BUCKET=... -HACKKIT_S3_REGION=... -HACKKIT_S3_ENDPOINT=... -HACKKIT_S3_ACCESS_KEY_ID=... -HACKKIT_S3_SECRET_ACCESS_KEY=... -DISCORD_GUILD_ID=... -DISCORD_BOT_API_URL=https://your-discord-bot.example.com -DISCORD_INTERNAL_AUTH_KEY=... -DISCORD_PARTICIPANT_ROLE_ID=... -``` - -Use `DISCORD_PARTICIPANT_ROLE_NAME` instead of `DISCORD_PARTICIPANT_ROLE_ID` only when role IDs are not available. Optional email delivery is configured with `HACKKIT_EMAIL_PROVIDER=resend` plus `RESEND_API_KEY`, or `HACKKIT_EMAIL_PROVIDER=smtp` plus `SMTP_HOST` and SMTP credentials. - -Run `pnpm --filter web db:migrate` and `pnpm --filter web db:seed` as release steps before starting the new production version. Do not run migrations or seed roles during the Next.js build or application startup. - -## Next integration path - -This app follows the single `@hackkit/next` integration path for runtime setup, page guards, Core mutations, and HackKit UI provider actions. See: - -- [`packages/next/README.md`](../../packages/next/README.md) — package API and wiring recipe -- [`docs/guides/next-integration.md`](../../docs/guides/next-integration.md) — full guide and drift checks - -## Release checks - -Run the same app checks locally before deploying: - -```bash -pnpm --filter web sync -pnpm --filter web schema:generate -pnpm --filter web auth:schema -pnpm --filter web db:generate -pnpm --filter web db:migrate -pnpm --filter web db:seed -pnpm --filter web test -pnpm --filter web test:db-workflow -pnpm --filter @hackkit/core test -pnpm --filter @hackkit/db-drizzle test -pnpm --filter @hackkit/cli test -pnpm --filter @hackkit/config test -pnpm --filter @hackkit/next test -pnpm --filter @hackkit/plugin-teams test -pnpm --filter @hackkit/plugin-discord test -pnpm --filter @hackkit/plugin-notifications-email test -pnpm --filter web typecheck -pnpm --filter web build -``` - -CI regenerates committed schema, validates and applies the web migration chain, runs a Better Auth flow against the generated auth schema, verifies database-command isolation, applies the committed PostgreSQL adapter migration fixture, and builds the full web dependency graph. - -## Server Actions - -UI mutations live on the Next runtime (`runtime.mutations` from `createHackKitMutations`). Named server actions and the `hackKitUIActions` provider map ship from `@hackkit/next` — [`app/providers.tsx`](app/providers.tsx) passes `hackKitUIActions` to `HackKitUIProvider`. Plugin actions remain generated in [`app/hackkit-plugin-actions.ts`](app/hackkit-plugin-actions.ts) by `hackkit plugin sync`. - -## Configuration - -- [`hackkit.config.ts`](hackkit.config.ts) — plugins, User Data options, Event Types, and the explicit database adapter -- [`lib/runtime.ts`](lib/runtime.ts) — `createHackkitRuntimeFromConfig` composition root; use `getPageGuards()` for layouts -- [`db/schema`](db/schema) — separately generated HackKit and Better Auth schema files -- [`db/migrations`](db/migrations) — app-owned Drizzle migrations +UI mutations and `hackKitUIActions` ship from `@hackkit/next`. Plugin server actions in `app/hackkit-plugin-actions.ts` are generated by `hackkit plugin sync` and share the same request runtime. diff --git a/apps/web/app/api/admin/export/route.ts b/apps/web/app/api/admin/export/route.ts index 4a20f69f..c281148b 100644 --- a/apps/web/app/api/admin/export/route.ts +++ b/apps/web/app/api/admin/export/route.ts @@ -1,3 +1,4 @@ +import { HackKitError } from "@hackkit/core"; import { NextResponse } from "next/server"; import { getAuthSession } from "@/lib/auth"; import { getRuntime } from "@/lib/runtime"; @@ -33,9 +34,17 @@ export async function GET() { const runtime = await getRuntime(); const actor = await runtime.getCurrentUser(); - const rows = await runtime.hackkit.admin.exportUsers({ - actorAuthId: actor.authId, - }); + const rows = await runtime.hackkit.admin + .exportUsers({ + actorAuthId: actor.authId, + }) + .catch((error: unknown) => { + if (error instanceof HackKitError && error.code === "FORBIDDEN") + return null; + throw error; + }); + if (!rows) + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const csv = toCsv(rows); const timestamp = new Date().toISOString().replaceAll(":", "-"); diff --git a/apps/web/app/api/auth/[...all]/route.ts b/apps/web/app/api/auth/[...all]/route.ts index 5b67b064..ac24ae8c 100644 --- a/apps/web/app/api/auth/[...all]/route.ts +++ b/apps/web/app/api/auth/[...all]/route.ts @@ -1,4 +1,9 @@ -import { auth } from "@/lib/auth"; -import { toNextJsHandler } from "better-auth/next-js"; +import { getAuth } from "@/lib/auth"; -export const { GET, POST } = toNextJsHandler(auth); +export async function GET(request: Request) { + return (await getAuth()).handler(request); +} + +export async function POST(request: Request) { + return (await getAuth()).handler(request); +} diff --git a/apps/web/app/api/files/upload/route.ts b/apps/web/app/api/files/upload/route.ts index 4e6cace2..dfae569f 100644 --- a/apps/web/app/api/files/upload/route.ts +++ b/apps/web/app/api/files/upload/route.ts @@ -32,7 +32,7 @@ export async function PUT(request: Request): Promise { } const filePath = await storage.ensureDirectoryForKey(key); - const body = Buffer.from(await request.arrayBuffer()); + const body = new Uint8Array(await request.arrayBuffer()); await writeFile(filePath, body); return new NextResponse(null, { status: 204 }); diff --git a/apps/web/app/api/files/view/route.ts b/apps/web/app/api/files/view/route.ts index 1529915c..1a6186e1 100644 --- a/apps/web/app/api/files/view/route.ts +++ b/apps/web/app/api/files/view/route.ts @@ -56,12 +56,8 @@ export async function GET(request: Request): Promise { } const body = result.body instanceof Uint8Array - ? result.body - : Buffer.isBuffer(result.body) - ? result.body - : Buffer.from( - await new Response(result.body).arrayBuffer(), - ); + ? Uint8Array.from(result.body).buffer + : await new Response(result.body).arrayBuffer(); return new NextResponse(body, { headers: { "Content-Type": diff --git a/apps/web/app/hackkit-plugin-actions.ts b/apps/web/app/hackkit-plugin-actions.ts index 2239d3ba..6d8e8808 100644 --- a/apps/web/app/hackkit-plugin-actions.ts +++ b/apps/web/app/hackkit-plugin-actions.ts @@ -3,48 +3,72 @@ // @hackkit-generated — do not edit import { getRuntime } from "@/lib/runtime"; -import { createTeamsActions } from "@hackkit/plugin-teams"; -import { createDiscordActions } from "@hackkit/plugin-discord"; +import { createTeamsActions } from "@hackkit/plugin-teams/actions"; +import { createDiscordActions } from "@hackkit/plugin-discord/actions"; -const teamsActionsPromise = getRuntime().then((runtime) => - createTeamsActions(runtime), -); - -export async function createTeam(...args: Parameters>["createTeam"]>) { - const actions = await teamsActionsPromise; +export async function createTeam( + ...args: Parameters< + Awaited>["createTeam"] + > +) { + const actions = await createTeamsActions(await getRuntime()); return actions.createTeam(...args); } -export async function inviteToTeam(...args: Parameters>["inviteToTeam"]>) { - const actions = await teamsActionsPromise; +export async function inviteToTeam( + ...args: Parameters< + Awaited>["inviteToTeam"] + > +) { + const actions = await createTeamsActions(await getRuntime()); return actions.inviteToTeam(...args); } -export async function respondToInvite(...args: Parameters>["respondToInvite"]>) { - const actions = await teamsActionsPromise; +export async function respondToInvite( + ...args: Parameters< + Awaited>["respondToInvite"] + > +) { + const actions = await createTeamsActions(await getRuntime()); return actions.respondToInvite(...args); } -export async function leaveTeam(...args: Parameters>["leaveTeam"]>) { - const actions = await teamsActionsPromise; +export async function leaveTeam( + ...args: Parameters< + Awaited>["leaveTeam"] + > +) { + const actions = await createTeamsActions(await getRuntime()); return actions.leaveTeam(...args); } -export async function removeMember(...args: Parameters>["removeMember"]>) { - const actions = await teamsActionsPromise; +export async function removeMember( + ...args: Parameters< + Awaited>["removeMember"] + > +) { + const actions = await createTeamsActions(await getRuntime()); return actions.removeMember(...args); } -const discordActionsPromise = getRuntime().then((runtime) => - createDiscordActions(runtime), -); - -export async function confirmDiscordVerification(...args: Parameters>["confirmDiscordVerification"]>) { - const actions = await discordActionsPromise; +export async function confirmDiscordVerification( + ...args: Parameters< + Awaited< + ReturnType + >["confirmDiscordVerification"] + > +) { + const actions = await createDiscordActions(await getRuntime()); return actions.confirmDiscordVerification(...args); } -export async function syncDiscordMemberRoles(...args: Parameters>["syncDiscordMemberRoles"]>) { - const actions = await discordActionsPromise; +export async function syncDiscordMemberRoles( + ...args: Parameters< + Awaited< + ReturnType + >["syncDiscordMemberRoles"] + > +) { + const actions = await createDiscordActions(await getRuntime()); return actions.syncDiscordMemberRoles(...args); } diff --git a/apps/web/app/onboarding/hacker/hacker-form-defaults.ts b/apps/web/app/onboarding/hacker/hacker-form-defaults.ts index 4d98a560..edd46c9e 100644 --- a/apps/web/app/onboarding/hacker/hacker-form-defaults.ts +++ b/apps/web/app/onboarding/hacker/hacker-form-defaults.ts @@ -5,14 +5,14 @@ export function toHackerFormDefaults(hacker: Hacker | null) { return { university: hacker.university, major: hacker.major, - schoolId: hacker.schoolId, + schoolId: hacker.schoolId ?? undefined, levelOfStudy: hacker.levelOfStudy, hackathonsAttended: hacker.hackathonsAttended, softwareExperience: hacker.softwareExperience, - heardFrom: hacker.heardFrom, - githubUrl: hacker.githubUrl, - linkedInUrl: hacker.linkedInUrl, - personalWebsiteUrl: hacker.personalWebsiteUrl, - resumeUrl: hacker.resumeUrl, + heardFrom: hacker.heardFrom ?? undefined, + githubUrl: hacker.githubUrl ?? undefined, + linkedInUrl: hacker.linkedInUrl ?? undefined, + personalWebsiteUrl: hacker.personalWebsiteUrl ?? undefined, + resumeUrl: hacker.resumeUrl ?? undefined, }; } diff --git a/apps/web/better-auth.config.ts b/apps/web/better-auth.config.ts deleted file mode 100644 index 16c826e9..00000000 --- a/apps/web/better-auth.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { createClient } from "@libsql/client"; -import { betterAuth } from "better-auth"; -import { drizzleAdapter } from "better-auth/adapters/drizzle"; -import { drizzle } from "drizzle-orm/libsql"; - -// Keep database-affecting Better Auth plugins in sync with lib/auth.ts. -// Runtime-only plugins such as nextCookies do not contribute schema. -export const auth = betterAuth({ - baseURL: "http://localhost:3000", - database: drizzleAdapter(drizzle(createClient({ url: ":memory:" })), { - provider: "sqlite", - }), -}); diff --git a/apps/web/db/__tests__/database-workflow.test.ts b/apps/web/db/__tests__/database-workflow.test.ts index edfcdc02..8ed8c1b6 100644 --- a/apps/web/db/__tests__/database-workflow.test.ts +++ b/apps/web/db/__tests__/database-workflow.test.ts @@ -1,96 +1,67 @@ -import { readFile } from "node:fs/promises"; +import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; -import { runDbSeed } from "@hackkit/cli"; -import { CorePermission, createHackkit } from "@hackkit/core"; -import { createDrizzleLibsqlAdapter } from "@hackkit/db-drizzle/libsql"; -import { createClient } from "@libsql/client"; -import { betterAuth } from "better-auth"; -import { drizzleAdapter } from "better-auth/adapters/drizzle"; -import { drizzle } from "drizzle-orm/libsql"; -import { migrate } from "drizzle-orm/libsql/migrator"; -import { afterEach, describe, expect, it } from "vitest"; -import { appJob } from "./fixtures/app-schema"; -import { account, session, user, verification } from "../schema/auth"; +import { tmpdir } from "node:os"; +import { runDbMigrate, runDbSeed } from "@hackkit/cli"; +import { initializeHackkit } from "@hackkit/core"; +import { SqliteDriver } from "@mikro-orm/sqlite"; +import { expect, it } from "vitest"; +import appConfig from "../../hackkit.config"; -const clients: ReturnType[] = []; - -afterEach(() => { - for (const client of clients.splice(0)) client.close(); -}); - -describe("committed web database workflow", () => { - it("runs Better Auth against its generated schema after app migrations", async () => { - const client = createClient({ url: ":memory:" }); - clients.push(client); - const db = drizzle(client); - await migrate(db, { - migrationsFolder: join(process.cwd(), "db/migrations"), - }); - await client.executeMultiple( - await readFile( - join(process.cwd(), "db/__tests__/fixtures/app-migration.sql"), - "utf8", - ), - ); - - const database = createDrizzleLibsqlAdapter(db); - await runDbSeed({ - database, - seedRoles: [ - { - id: "core.participant", - name: "Participant", - position: 10, - permissions: [], - }, - { - id: "core.owner", - name: "Owner", - position: 0, - permissions: [CorePermission.SuperAdmin], - }, - ], - }); - const hackkit = createHackkit({ database }); - await expect(hackkit.roles.listRoles()).resolves.toMatchObject([ - { id: "core.owner", name: "Owner" }, - { id: "core.participant", name: "Participant" }, - ]); - - await db.insert(appJob).values({ id: "job-1", status: "ready" }); - await expect(db.select().from(appJob)).resolves.toEqual([ - { id: "job-1", status: "ready" }, - ]); - - const auth = betterAuth({ +it("runs Core, plugins, and Better Auth against committed app migrations", async () => { + const directory = await mkdtemp(join(tmpdir(), "hackkit-web-db-")); + const config = { + ...appConfig, + database: { + ...appConfig.database, + driver: SqliteDriver, + dbName: join(directory, "web.db"), + clientUrl: undefined, + migrations: { path: join(process.cwd(), "db/migrations/sqlite") }, + }, + auth: { baseURL: "http://localhost:3000", secret: "database-workflow-test-secret-at-least-32-characters", - database: drizzleAdapter(db, { - provider: "sqlite", - schema: { user, session, account, verification }, - camelCase: true, - }), emailAndPassword: { enabled: true }, - rateLimit: { enabled: false }, - }); - - const signup = await auth.api.signUpEmail({ - body: { - name: "Schema Test", + }, + logger: { disabled: true }, + }; + try { + await runDbMigrate(config); + await runDbSeed(config); + const core = await initializeHackkit(config); + try { + expect(await core.orm.schema.getUpdateSchemaSQL()).toBe(""); + const scope = core.createScope(); + expect(await scope.hackkit.roles.listRoles()).toMatchObject([ + { id: "core.owner" }, + { id: "core.participant" }, + ]); + const signup = await core.auth.api.signUpEmail({ + body: { + name: "Schema Test", + email: "schema@example.com", + password: "correct-horse-battery-staple", + }, + returnHeaders: true, + }); + const cookie = signup.headers.get("set-cookie"); + expect(cookie).toContain("better-auth.session_token="); + const session = await core.auth.api.getSession({ + headers: new Headers({ cookie: cookie ?? "" }), + }); + expect(session?.user.email).toBe("schema@example.com"); + expect( + await scope.hackkit.users.getUser(signup.response.user.id), + ).toMatchObject({ + authId: signup.response.user.id, + firstName: "Schema", + lastName: "Test", email: "schema@example.com", - password: "correct-horse-battery-staple", - }, - returnHeaders: true, - }); - expect(signup.response.user.email).toBe("schema@example.com"); - - const setCookie = signup.headers.get("set-cookie"); - expect(setCookie).toContain("better-auth.session_token="); - const cookie = setCookie?.split(";", 1)[0]; - const currentSession = await auth.api.getSession({ - headers: new Headers({ cookie: cookie ?? "" }), - }); - - expect(currentSession?.user.email).toBe("schema@example.com"); - }); + }); + } finally { + await core.orm.close(); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } }); diff --git a/apps/web/db/__tests__/dialects.test.ts b/apps/web/db/__tests__/dialects.test.ts new file mode 100644 index 00000000..09d44d30 --- /dev/null +++ b/apps/web/db/__tests__/dialects.test.ts @@ -0,0 +1,240 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { SqliteDriver } from "@mikro-orm/sqlite"; +import { LibSqlDriver } from "@mikro-orm/libsql"; +import { MySqlDriver } from "@mikro-orm/mysql"; +import { PostgreSqlDriver } from "@mikro-orm/postgresql"; +import { runDbMigrate, runDbSeed } from "@hackkit/cli"; +import { + AuthUser, + CoreSetting, + coreModels, + initializeHackkit, + type HackKitDatabaseOptions, +} from "@hackkit/core"; +import appConfig from "../../hackkit.config"; + +const externalTargets = [ + { + name: "PostgreSQL", + dialect: "postgresql", + driver: PostgreSqlDriver, + url: process.env.HACKKIT_TEST_POSTGRESQL_URL, + }, + { + name: "MySQL", + dialect: "mysql", + driver: MySqlDriver, + url: process.env.HACKKIT_TEST_MYSQL_URL, + }, + { + name: "network libSQL", + dialect: "sqlite", + driver: LibSqlDriver, + url: process.env.HACKKIT_TEST_LIBSQL_URL, + }, +]; +if ( + process.env.HACKKIT_REQUIRE_DATABASE_MATRIX === "1" && + externalTargets.some((target) => !target.url) +) { + throw new Error( + "The required database matrix needs PostgreSQL, MySQL, and network libSQL test URLs.", + ); +} + +// These URLs must address empty, disposable test databases. This test never drops a database. +const targets = [ + { name: "SQLite", dialect: "sqlite", driver: SqliteDriver, url: "local" }, + { + name: "local libSQL", + dialect: "sqlite", + driver: LibSqlDriver, + url: "local", + }, + ...externalTargets, +]; + +for (const target of targets) { + describe.skipIf(!target.url)(target.name, () => { + it("applies app migrations and preserves auth, domain types, rollback, and capacity under concurrency", async () => { + const directory = await mkdtemp(join(tmpdir(), "hackkit-dialect-")); + const database: HackKitDatabaseOptions = { + driver: target.driver, + ...(target.url === "local" + ? { dbName: join(directory, "test.db") } + : target.driver === LibSqlDriver + ? { dbName: target.url! } + : { clientUrl: target.url! }), + migrations: { + path: join(process.cwd(), "db/migrations", target.dialect), + snapshotOnMigrate: false, + }, + }; + const config = { + ...appConfig, + database, + auth: { + baseURL: "http://localhost:3000", + secret: "matrix-secret-with-at-least-32-characters", + emailAndPassword: { enabled: true }, + }, + logger: { disabled: true }, + }; + try { + await runDbMigrate(config); + await runDbSeed(config); + const core = await initializeHackkit(config); + try { + expect(await core.orm.schema.getUpdateSchemaSQL()).toBe(""); + const signup = async (name: string) => { + const result = await core.auth.api.signUpEmail({ + body: { + name, + email: `${name.toLowerCase()}@example.com`, + password: "a sufficiently long password", + }, + returnHeaders: true, + }); + const cookie = result.headers.get("set-cookie"); + const session = await core.auth.api.getSession({ + headers: new Headers({ cookie: cookie ?? "" }), + }); + expect(session?.user.id).toBe(result.response.user.id); + return result.response.user.id; + }; + const ownerId = await signup("Owner"); + const ownerScope = core.createScope(ownerId); + const owner = await ownerScope.em.findOneOrFail( + coreModels.user, + { authId: ownerId }, + ); + owner.roleId = "core.owner"; + await ownerScope.em.flush(); + await ownerScope.hackkit.settings.set({ + actorAuthId: ownerId, + key: CoreSetting.MaximumRegistrations, + value: 1, + }); + const ids = []; + for (const name of ["One", "Two"]) { + const authId = await signup(name); + ids.push(authId); + const scope = core.createScope(authId); + await scope.hackkit.users.claimHackTag({ + authId, + hackTag: name.toLowerCase(), + }); + await scope.hackkit.userData.completeUserData({ + authId, + age: 21, + gender: "prefer_not_to_answer", + race: "prefer_not_to_answer", + ethnicity: "prefer_not_to_answer", + shirtSize: "m", + dietaryRestrictions: ["none"], + hasAcceptedMLHCodeOfConduct: true, + hasSharedDataWithMLH: true, + isEmailable: false, + }); + } + const results = await Promise.allSettled( + ids.map((authId) => + core + .createScope(authId) + .hackkit.hackers.registerHacker({ + authId, + university: "UTSA", + major: "Computer Science", + levelOfStudy: "undergraduate", + hackathonsAttended: 0, + softwareExperience: "beginner", + }), + ), + ); + expect( + results.filter( + (result) => result.status === "fulfilled", + ), + ).toHaveLength(1); + expect( + results.find((result) => result.status === "rejected"), + ).toMatchObject({ reason: { code: "INVALID_OPERATION" } }); + const hacker = await core + .createScope() + .em.findOneOrFail(coreModels.hacker, { + authId: { $in: ids }, + }); + const participant = core.createScope(hacker.authId); + const profile = await participant.hackkit.users.getUser( + hacker.authId, + ); + expect(profile).toMatchObject({ + roleId: "core.participant", + isApproved: true, + checkedInAt: null, + }); + expect(profile?.createdAt).toBeInstanceOf(Date); + expect(Object.getPrototypeOf(profile)).toBe( + Object.prototype, + ); + const data = await participant.hackkit.userData.getUserData( + hacker.authId, + ); + expect(data).toMatchObject({ + dietaryRestrictions: ["none"], + isEmailable: false, + phoneNumber: null, + }); + await expect( + core.createScope().em.transactional(async (em) => { + const record = await em.findOneOrFail( + coreModels.user, + { authId: hacker.authId }, + ); + record.bio = "must roll back"; + await em.flush(); + throw new Error("abort matrix transaction"); + }), + ).rejects.toThrow("abort matrix transaction"); + expect( + ( + await core + .createScope() + .hackkit.users.getUser(hacker.authId) + )?.bio, + ).toBeNull(); + const team = + await participant.hackkit.plugins.teams.createTeam({ + actorAuthId: hacker.authId, + name: "Typed team", + tag: "typed", + }); + expect(team.members[0]?.user.authId).toBe(hacker.authId); + expect(Object.getPrototypeOf(team)).toBe(Object.prototype); + await expect( + core.createScope().hackkit.users.claimHackTag({ + authId: ownerId, + hackTag: profile!.hackTag!, + }), + ).rejects.toMatchObject({ code: "CONFLICT" }); + const cleanup = core.createScope().em; + await cleanup.nativeDelete(AuthUser, { + id: ids.find((id) => id !== hacker.authId)!, + }); + expect( + await core + .createScope() + .em.count(coreModels.userData, {}), + ).toBe(1); + } finally { + await core.orm.close(); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } + }, 30000); + }); +} diff --git a/apps/web/db/__tests__/fixtures/app-migration.sql b/apps/web/db/__tests__/fixtures/app-migration.sql deleted file mode 100644 index a8c1d436..00000000 --- a/apps/web/db/__tests__/fixtures/app-migration.sql +++ /dev/null @@ -1,4 +0,0 @@ -CREATE TABLE `app_test_job` ( - `id` text PRIMARY KEY NOT NULL, - `status` text NOT NULL -); diff --git a/apps/web/db/__tests__/fixtures/app-schema.ts b/apps/web/db/__tests__/fixtures/app-schema.ts deleted file mode 100644 index d4d620aa..00000000 --- a/apps/web/db/__tests__/fixtures/app-schema.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { sqliteTable, text } from "drizzle-orm/sqlite-core"; - -export const appJob = sqliteTable("app_test_job", { - id: text("id").primaryKey().notNull(), - status: text("status").notNull(), -}); diff --git a/apps/web/db/migrations/0000_init.sql b/apps/web/db/migrations/0000_init.sql deleted file mode 100644 index 11cf9fbe..00000000 --- a/apps/web/db/migrations/0000_init.sql +++ /dev/null @@ -1,329 +0,0 @@ -CREATE TABLE `account` ( - `id` text PRIMARY KEY NOT NULL, - `account_id` text NOT NULL, - `provider_id` text NOT NULL, - `user_id` text NOT NULL, - `access_token` text, - `refresh_token` text, - `id_token` text, - `access_token_expires_at` integer, - `refresh_token_expires_at` integer, - `scope` text, - `password` text, - `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, - `updated_at` integer NOT NULL, - FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade -); ---> statement-breakpoint -CREATE INDEX `account_userId_idx` ON `account` (`user_id`);--> statement-breakpoint -CREATE TABLE `session` ( - `id` text PRIMARY KEY NOT NULL, - `expires_at` integer NOT NULL, - `token` text NOT NULL, - `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, - `updated_at` integer NOT NULL, - `ip_address` text, - `user_agent` text, - `user_id` text NOT NULL, - FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade -); ---> statement-breakpoint -CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint -CREATE INDEX `session_userId_idx` ON `session` (`user_id`);--> statement-breakpoint -CREATE TABLE `user` ( - `id` text PRIMARY KEY NOT NULL, - `name` text NOT NULL, - `email` text NOT NULL, - `email_verified` integer DEFAULT false NOT NULL, - `image` text, - `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, - `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL -); ---> statement-breakpoint -CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint -CREATE TABLE `verification` ( - `id` text PRIMARY KEY NOT NULL, - `identifier` text NOT NULL, - `value` text NOT NULL, - `expires_at` integer NOT NULL, - `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, - `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL -); ---> statement-breakpoint -CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`);--> statement-breakpoint -CREATE TABLE `core_event` ( - `id` text PRIMARY KEY NOT NULL, - `title` text NOT NULL, - `startTime` integer NOT NULL, - `endTime` integer NOT NULL, - `location` text DEFAULT 'TBD' NOT NULL, - `description` text NOT NULL, - `type` text NOT NULL, - `host` text, - `hidden` integer DEFAULT false NOT NULL, - `createdAt` integer NOT NULL, - `updatedAt` integer NOT NULL -); ---> statement-breakpoint -CREATE INDEX `core_event_startTime_idx` ON `core_event` (`startTime`);--> statement-breakpoint -CREATE INDEX `core_event_type_idx` ON `core_event` (`type`);--> statement-breakpoint -CREATE INDEX `core_event_hidden_idx` ON `core_event` (`hidden`);--> statement-breakpoint -CREATE TABLE `core_eventScan` ( - `id` text PRIMARY KEY NOT NULL, - `eventId` text NOT NULL, - `authId` text NOT NULL, - `scannedByAuthId` text NOT NULL, - `scannedAt` integer NOT NULL, - FOREIGN KEY (`eventId`) REFERENCES `core_event`(`id`) ON UPDATE no action ON DELETE cascade, - FOREIGN KEY (`authId`) REFERENCES `core_user`(`authId`) ON UPDATE no action ON DELETE cascade, - FOREIGN KEY (`scannedByAuthId`) REFERENCES `core_user`(`authId`) ON UPDATE no action ON DELETE no action -); ---> statement-breakpoint -CREATE INDEX `core_eventScan_eventId_idx` ON `core_eventScan` (`eventId`);--> statement-breakpoint -CREATE INDEX `core_eventScan_authId_idx` ON `core_eventScan` (`authId`);--> statement-breakpoint -CREATE INDEX `core_eventScan_eventId_authId_idx` ON `core_eventScan` (`eventId`,`authId`);--> statement-breakpoint -CREATE INDEX `core_eventScan_scannedAt_idx` ON `core_eventScan` (`scannedAt`);--> statement-breakpoint -CREATE TABLE `core_hacker` ( - `authId` text PRIMARY KEY NOT NULL, - `university` text NOT NULL, - `major` text NOT NULL, - `schoolId` text, - `levelOfStudy` text NOT NULL, - `hackathonsAttended` integer NOT NULL, - `softwareExperience` text NOT NULL, - `heardFrom` text, - `githubUrl` text, - `linkedInUrl` text, - `personalWebsiteUrl` text, - `resumeUrl` text, - `group` text, - `registeredAt` integer NOT NULL, - `updatedAt` integer NOT NULL, - FOREIGN KEY (`authId`) REFERENCES `core_user`(`authId`) ON UPDATE no action ON DELETE cascade -); ---> statement-breakpoint -CREATE INDEX `core_hacker_registeredAt_idx` ON `core_hacker` (`registeredAt`);--> statement-breakpoint -CREATE TABLE `core_notificationDeliveryAttempt` ( - `id` text PRIMARY KEY NOT NULL, - `intentId` text NOT NULL, - `channel` text NOT NULL, - `provider` text, - `status` text NOT NULL, - `recipient` text, - `externalId` text, - `error` text, - `metadata` text DEFAULT '{}' NOT NULL, - `attemptedAt` integer NOT NULL, - FOREIGN KEY (`intentId`) REFERENCES `core_notificationIntent`(`id`) ON UPDATE no action ON DELETE cascade -); ---> statement-breakpoint -CREATE INDEX `core_notificationDeliveryAttempt_intentId_idx` ON `core_notificationDeliveryAttempt` (`intentId`);--> statement-breakpoint -CREATE INDEX `core_notificationDeliveryAttempt_channel_idx` ON `core_notificationDeliveryAttempt` (`channel`);--> statement-breakpoint -CREATE INDEX `core_notificationDeliveryAttempt_status_idx` ON `core_notificationDeliveryAttempt` (`status`);--> statement-breakpoint -CREATE INDEX `core_notificationDeliveryAttempt_attemptedAt_idx` ON `core_notificationDeliveryAttempt` (`attemptedAt`);--> statement-breakpoint -CREATE TABLE `core_notificationIntent` ( - `id` text PRIMARY KEY NOT NULL, - `kind` text NOT NULL, - `recipientAuthId` text, - `payload` text DEFAULT '{}' NOT NULL, - `status` text DEFAULT 'pending' NOT NULL, - `idempotencyKey` text, - `createdAt` integer NOT NULL, - `updatedAt` integer NOT NULL, - FOREIGN KEY (`recipientAuthId`) REFERENCES `core_user`(`authId`) ON UPDATE no action ON DELETE set null -); ---> statement-breakpoint -CREATE UNIQUE INDEX `core_notificationIntent_idempotencyKey_unique` ON `core_notificationIntent` (`idempotencyKey`);--> statement-breakpoint -CREATE INDEX `core_notificationIntent_kind_idx` ON `core_notificationIntent` (`kind`);--> statement-breakpoint -CREATE INDEX `core_notificationIntent_recipientAuthId_idx` ON `core_notificationIntent` (`recipientAuthId`);--> statement-breakpoint -CREATE INDEX `core_notificationIntent_status_idx` ON `core_notificationIntent` (`status`);--> statement-breakpoint -CREATE INDEX `core_notificationIntent_createdAt_idx` ON `core_notificationIntent` (`createdAt`);--> statement-breakpoint -CREATE TABLE `core_role` ( - `id` text PRIMARY KEY NOT NULL, - `name` text NOT NULL, - `position` integer NOT NULL, - `permissions` text DEFAULT '[]' NOT NULL, - `color` text, - `createdAt` integer NOT NULL, - `updatedAt` integer NOT NULL -); ---> statement-breakpoint -CREATE UNIQUE INDEX `core_role_name_unique` ON `core_role` (`name`);--> statement-breakpoint -CREATE INDEX `core_role_position_idx` ON `core_role` (`position`);--> statement-breakpoint -CREATE TABLE `core_rsvp` ( - `authId` text PRIMARY KEY NOT NULL, - `status` text NOT NULL, - `waitlistPosition` integer, - `createdAt` integer NOT NULL, - `updatedAt` integer NOT NULL, - `confirmedAt` integer, - `waitlistedAt` integer, - `cancelledAt` integer, - `cancelledByAuthId` text, - `promotedAt` integer, - `promotedByAuthId` text, - FOREIGN KEY (`authId`) REFERENCES `core_user`(`authId`) ON UPDATE no action ON DELETE cascade, - FOREIGN KEY (`cancelledByAuthId`) REFERENCES `core_user`(`authId`) ON UPDATE no action ON DELETE set null, - FOREIGN KEY (`promotedByAuthId`) REFERENCES `core_user`(`authId`) ON UPDATE no action ON DELETE set null -); ---> statement-breakpoint -CREATE INDEX `core_rsvp_status_idx` ON `core_rsvp` (`status`);--> statement-breakpoint -CREATE INDEX `core_rsvp_waitlistPosition_idx` ON `core_rsvp` (`waitlistPosition`);--> statement-breakpoint -CREATE INDEX `core_rsvp_createdAt_idx` ON `core_rsvp` (`createdAt`);--> statement-breakpoint -CREATE INDEX `core_rsvp_updatedAt_idx` ON `core_rsvp` (`updatedAt`);--> statement-breakpoint -CREATE TABLE `core_setting` ( - `key` text PRIMARY KEY NOT NULL, - `value` text NOT NULL, - `createdAt` integer NOT NULL, - `createdByAuthId` text, - `updatedAt` integer NOT NULL, - `updatedByAuthId` text, - FOREIGN KEY (`createdByAuthId`) REFERENCES `core_user`(`authId`) ON UPDATE no action ON DELETE set null, - FOREIGN KEY (`updatedByAuthId`) REFERENCES `core_user`(`authId`) ON UPDATE no action ON DELETE set null -); ---> statement-breakpoint -CREATE INDEX `core_setting_updatedAt_idx` ON `core_setting` (`updatedAt`);--> statement-breakpoint -CREATE INDEX `core_setting_updatedByAuthId_idx` ON `core_setting` (`updatedByAuthId`);--> statement-breakpoint -CREATE TABLE `core_user` ( - `authId` text PRIMARY KEY NOT NULL, - `email` text NOT NULL, - `firstName` text NOT NULL, - `lastName` text NOT NULL, - `profilePhotoUrl` text, - `hackTag` text, - `bio` text, - `pronouns` text, - `skills` text DEFAULT '[]' NOT NULL, - `isProfileSearchable` integer DEFAULT true NOT NULL, - `discordDisplayHandle` text, - `roleId` text, - `isApproved` integer DEFAULT false NOT NULL, - `checkedInAt` integer, - `createdAt` integer NOT NULL, - `updatedAt` integer NOT NULL, - FOREIGN KEY (`roleId`) REFERENCES `core_role`(`id`) ON UPDATE no action ON DELETE set null -); ---> statement-breakpoint -CREATE UNIQUE INDEX `core_user_email_unique` ON `core_user` (`email`);--> statement-breakpoint -CREATE UNIQUE INDEX `core_user_hackTag_unique` ON `core_user` (`hackTag`);--> statement-breakpoint -CREATE INDEX `core_user_email_idx` ON `core_user` (`email`);--> statement-breakpoint -CREATE INDEX `core_user_hackTag_idx` ON `core_user` (`hackTag`);--> statement-breakpoint -CREATE INDEX `core_user_roleId_idx` ON `core_user` (`roleId`);--> statement-breakpoint -CREATE INDEX `core_user_createdAt_idx` ON `core_user` (`createdAt`);--> statement-breakpoint -CREATE TABLE `core_userBan` ( - `authId` text PRIMARY KEY NOT NULL, - `reason` text, - `bannedByAuthId` text NOT NULL, - `createdAt` integer NOT NULL, - FOREIGN KEY (`authId`) REFERENCES `core_user`(`authId`) ON UPDATE no action ON DELETE cascade, - FOREIGN KEY (`bannedByAuthId`) REFERENCES `core_user`(`authId`) ON UPDATE no action ON DELETE no action -); ---> statement-breakpoint -CREATE TABLE `core_userData` ( - `authId` text PRIMARY KEY NOT NULL, - `age` integer NOT NULL, - `gender` text NOT NULL, - `race` text NOT NULL, - `ethnicity` text NOT NULL, - `shirtSize` text NOT NULL, - `dietaryRestrictions` text DEFAULT '[]' NOT NULL, - `accommodationNote` text, - `phoneNumber` text, - `countryOfResidence` text, - `hasAcceptedMLHCodeOfConduct` integer NOT NULL, - `hasSharedDataWithMLH` integer NOT NULL, - `isEmailable` integer NOT NULL, - `completedAt` integer NOT NULL, - `updatedAt` integer NOT NULL, - FOREIGN KEY (`authId`) REFERENCES `core_user`(`authId`) ON UPDATE no action ON DELETE cascade -); ---> statement-breakpoint -CREATE TABLE `discord_member` ( - `authId` text PRIMARY KEY NOT NULL, - `discordUserId` text NOT NULL, - `guildId` text NOT NULL, - `username` text NOT NULL, - `avatarHash` text, - `verifiedAt` integer NOT NULL, - `updatedAt` integer NOT NULL, - `lastRoleSyncAt` integer, - FOREIGN KEY (`authId`) REFERENCES `core_user`(`authId`) ON UPDATE no action ON DELETE cascade -); ---> statement-breakpoint -CREATE UNIQUE INDEX `discord_member_discordUserId_unique` ON `discord_member` (`discordUserId`);--> statement-breakpoint -CREATE INDEX `discord_member_discordUserId_idx` ON `discord_member` (`discordUserId`);--> statement-breakpoint -CREATE INDEX `discord_member_guildId_idx` ON `discord_member` (`guildId`);--> statement-breakpoint -CREATE INDEX `discord_member_updatedAt_idx` ON `discord_member` (`updatedAt`);--> statement-breakpoint -CREATE TABLE `discord_roleSyncAttempt` ( - `id` text PRIMARY KEY NOT NULL, - `authId` text NOT NULL, - `discordUserId` text NOT NULL, - `guildId` text NOT NULL, - `status` text NOT NULL, - `roleIds` text DEFAULT '[]' NOT NULL, - `roleNames` text DEFAULT '[]' NOT NULL, - `error` text, - `createdAt` integer NOT NULL, - FOREIGN KEY (`authId`) REFERENCES `core_user`(`authId`) ON UPDATE no action ON DELETE cascade -); ---> statement-breakpoint -CREATE INDEX `discord_roleSyncAttempt_authId_idx` ON `discord_roleSyncAttempt` (`authId`);--> statement-breakpoint -CREATE INDEX `discord_roleSyncAttempt_discordUserId_idx` ON `discord_roleSyncAttempt` (`discordUserId`);--> statement-breakpoint -CREATE INDEX `discord_roleSyncAttempt_status_idx` ON `discord_roleSyncAttempt` (`status`);--> statement-breakpoint -CREATE INDEX `discord_roleSyncAttempt_createdAt_idx` ON `discord_roleSyncAttempt` (`createdAt`);--> statement-breakpoint -CREATE TABLE `discord_verification` ( - `code` text PRIMARY KEY NOT NULL, - `discordUserId` text NOT NULL, - `guildId` text NOT NULL, - `username` text NOT NULL, - `avatarHash` text, - `authId` text, - `status` text DEFAULT 'pending' NOT NULL, - `expiresAt` integer NOT NULL, - `createdAt` integer NOT NULL, - `acceptedAt` integer, - FOREIGN KEY (`authId`) REFERENCES `core_user`(`authId`) ON UPDATE no action ON DELETE set null -); ---> statement-breakpoint -CREATE INDEX `discord_verification_discordUserId_idx` ON `discord_verification` (`discordUserId`);--> statement-breakpoint -CREATE INDEX `discord_verification_guildId_idx` ON `discord_verification` (`guildId`);--> statement-breakpoint -CREATE INDEX `discord_verification_authId_idx` ON `discord_verification` (`authId`);--> statement-breakpoint -CREATE INDEX `discord_verification_status_idx` ON `discord_verification` (`status`);--> statement-breakpoint -CREATE INDEX `discord_verification_createdAt_idx` ON `discord_verification` (`createdAt`);--> statement-breakpoint -CREATE TABLE `teams_invite` ( - `id` text PRIMARY KEY NOT NULL, - `teamId` text NOT NULL, - `inviteeAuthId` text NOT NULL, - `status` text DEFAULT 'pending' NOT NULL, - `createdAt` integer NOT NULL, - FOREIGN KEY (`teamId`) REFERENCES `teams_team`(`id`) ON UPDATE no action ON DELETE cascade, - FOREIGN KEY (`inviteeAuthId`) REFERENCES `core_user`(`authId`) ON UPDATE no action ON DELETE cascade -); ---> statement-breakpoint -CREATE INDEX `teams_invite_teamId_idx` ON `teams_invite` (`teamId`);--> statement-breakpoint -CREATE INDEX `teams_invite_inviteeAuthId_idx` ON `teams_invite` (`inviteeAuthId`);--> statement-breakpoint -CREATE INDEX `teams_invite_teamId_inviteeAuthId_idx` ON `teams_invite` (`teamId`,`inviteeAuthId`);--> statement-breakpoint -CREATE TABLE `teams_member` ( - `id` text PRIMARY KEY NOT NULL, - `teamId` text NOT NULL, - `authId` text NOT NULL, - `joinedAt` integer NOT NULL, - FOREIGN KEY (`teamId`) REFERENCES `teams_team`(`id`) ON UPDATE no action ON DELETE cascade, - FOREIGN KEY (`authId`) REFERENCES `core_user`(`authId`) ON UPDATE no action ON DELETE cascade -); ---> statement-breakpoint -CREATE UNIQUE INDEX `teams_member_authId_unique` ON `teams_member` (`authId`);--> statement-breakpoint -CREATE INDEX `teams_member_teamId_idx` ON `teams_member` (`teamId`);--> statement-breakpoint -CREATE INDEX `teams_member_authId_idx` ON `teams_member` (`authId`);--> statement-breakpoint -CREATE TABLE `teams_team` ( - `id` text PRIMARY KEY NOT NULL, - `name` text NOT NULL, - `tag` text NOT NULL, - `ownerAuthId` text NOT NULL, - `createdAt` integer NOT NULL, - FOREIGN KEY (`ownerAuthId`) REFERENCES `core_user`(`authId`) ON UPDATE no action ON DELETE cascade -); ---> statement-breakpoint -CREATE UNIQUE INDEX `teams_team_tag_unique` ON `teams_team` (`tag`);--> statement-breakpoint -CREATE INDEX `teams_team_tag_idx` ON `teams_team` (`tag`);--> statement-breakpoint -CREATE INDEX `teams_team_ownerAuthId_idx` ON `teams_team` (`ownerAuthId`); \ No newline at end of file diff --git a/apps/web/db/migrations/meta/0000_snapshot.json b/apps/web/db/migrations/meta/0000_snapshot.json deleted file mode 100644 index 911afa26..00000000 --- a/apps/web/db/migrations/meta/0000_snapshot.json +++ /dev/null @@ -1,2299 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "ef7b72a8-e504-4a70-9e7d-6b669636f757", - "prevId": "00000000-0000-0000-0000-000000000000", - "tables": { - "account": { - "name": "account", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "account_id": { - "name": "account_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "access_token": { - "name": "access_token", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "refresh_token": { - "name": "refresh_token", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "id_token": { - "name": "id_token", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "access_token_expires_at": { - "name": "access_token_expires_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "refresh_token_expires_at": { - "name": "refresh_token_expires_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "password": { - "name": "password", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(cast(unixepoch('subsecond') * 1000 as integer))" - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "account_userId_idx": { - "name": "account_userId_idx", - "columns": [ - "user_id" - ], - "isUnique": false - } - }, - "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": {}, - "checkConstraints": {} - }, - "session": { - "name": "session", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "expires_at": { - "name": "expires_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(cast(unixepoch('subsecond') * 1000 as integer))" - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "ip_address": { - "name": "ip_address", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "user_agent": { - "name": "user_agent", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "session_token_unique": { - "name": "session_token_unique", - "columns": [ - "token" - ], - "isUnique": true - }, - "session_userId_idx": { - "name": "session_userId_idx", - "columns": [ - "user_id" - ], - "isUnique": false - } - }, - "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": {}, - "checkConstraints": {} - }, - "user": { - "name": "user", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "email_verified": { - "name": "email_verified", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "image": { - "name": "image", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(cast(unixepoch('subsecond') * 1000 as integer))" - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(cast(unixepoch('subsecond') * 1000 as integer))" - } - }, - "indexes": { - "user_email_unique": { - "name": "user_email_unique", - "columns": [ - "email" - ], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "verification": { - "name": "verification", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "identifier": { - "name": "identifier", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "expires_at": { - "name": "expires_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(cast(unixepoch('subsecond') * 1000 as integer))" - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(cast(unixepoch('subsecond') * 1000 as integer))" - } - }, - "indexes": { - "verification_identifier_idx": { - "name": "verification_identifier_idx", - "columns": [ - "identifier" - ], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "core_event": { - "name": "core_event", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "startTime": { - "name": "startTime", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "endTime": { - "name": "endTime", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "location": { - "name": "location", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'TBD'" - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "host": { - "name": "host", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "hidden": { - "name": "hidden", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "createdAt": { - "name": "createdAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updatedAt": { - "name": "updatedAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "core_event_startTime_idx": { - "name": "core_event_startTime_idx", - "columns": [ - "startTime" - ], - "isUnique": false - }, - "core_event_type_idx": { - "name": "core_event_type_idx", - "columns": [ - "type" - ], - "isUnique": false - }, - "core_event_hidden_idx": { - "name": "core_event_hidden_idx", - "columns": [ - "hidden" - ], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "core_eventScan": { - "name": "core_eventScan", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "eventId": { - "name": "eventId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "authId": { - "name": "authId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scannedByAuthId": { - "name": "scannedByAuthId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scannedAt": { - "name": "scannedAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "core_eventScan_eventId_idx": { - "name": "core_eventScan_eventId_idx", - "columns": [ - "eventId" - ], - "isUnique": false - }, - "core_eventScan_authId_idx": { - "name": "core_eventScan_authId_idx", - "columns": [ - "authId" - ], - "isUnique": false - }, - "core_eventScan_eventId_authId_idx": { - "name": "core_eventScan_eventId_authId_idx", - "columns": [ - "eventId", - "authId" - ], - "isUnique": false - }, - "core_eventScan_scannedAt_idx": { - "name": "core_eventScan_scannedAt_idx", - "columns": [ - "scannedAt" - ], - "isUnique": false - } - }, - "foreignKeys": { - "core_eventScan_eventId_core_event_id_fk": { - "name": "core_eventScan_eventId_core_event_id_fk", - "tableFrom": "core_eventScan", - "tableTo": "core_event", - "columnsFrom": [ - "eventId" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "core_eventScan_authId_core_user_authId_fk": { - "name": "core_eventScan_authId_core_user_authId_fk", - "tableFrom": "core_eventScan", - "tableTo": "core_user", - "columnsFrom": [ - "authId" - ], - "columnsTo": [ - "authId" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "core_eventScan_scannedByAuthId_core_user_authId_fk": { - "name": "core_eventScan_scannedByAuthId_core_user_authId_fk", - "tableFrom": "core_eventScan", - "tableTo": "core_user", - "columnsFrom": [ - "scannedByAuthId" - ], - "columnsTo": [ - "authId" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "core_hacker": { - "name": "core_hacker", - "columns": { - "authId": { - "name": "authId", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "university": { - "name": "university", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "major": { - "name": "major", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "schoolId": { - "name": "schoolId", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "levelOfStudy": { - "name": "levelOfStudy", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "hackathonsAttended": { - "name": "hackathonsAttended", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "softwareExperience": { - "name": "softwareExperience", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "heardFrom": { - "name": "heardFrom", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "githubUrl": { - "name": "githubUrl", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "linkedInUrl": { - "name": "linkedInUrl", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "personalWebsiteUrl": { - "name": "personalWebsiteUrl", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "resumeUrl": { - "name": "resumeUrl", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "group": { - "name": "group", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "registeredAt": { - "name": "registeredAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updatedAt": { - "name": "updatedAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "core_hacker_registeredAt_idx": { - "name": "core_hacker_registeredAt_idx", - "columns": [ - "registeredAt" - ], - "isUnique": false - } - }, - "foreignKeys": { - "core_hacker_authId_core_user_authId_fk": { - "name": "core_hacker_authId_core_user_authId_fk", - "tableFrom": "core_hacker", - "tableTo": "core_user", - "columnsFrom": [ - "authId" - ], - "columnsTo": [ - "authId" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "core_notificationDeliveryAttempt": { - "name": "core_notificationDeliveryAttempt", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "intentId": { - "name": "intentId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "channel": { - "name": "channel", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "recipient": { - "name": "recipient", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "externalId": { - "name": "externalId", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "error": { - "name": "error", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "metadata": { - "name": "metadata", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'{}'" - }, - "attemptedAt": { - "name": "attemptedAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "core_notificationDeliveryAttempt_intentId_idx": { - "name": "core_notificationDeliveryAttempt_intentId_idx", - "columns": [ - "intentId" - ], - "isUnique": false - }, - "core_notificationDeliveryAttempt_channel_idx": { - "name": "core_notificationDeliveryAttempt_channel_idx", - "columns": [ - "channel" - ], - "isUnique": false - }, - "core_notificationDeliveryAttempt_status_idx": { - "name": "core_notificationDeliveryAttempt_status_idx", - "columns": [ - "status" - ], - "isUnique": false - }, - "core_notificationDeliveryAttempt_attemptedAt_idx": { - "name": "core_notificationDeliveryAttempt_attemptedAt_idx", - "columns": [ - "attemptedAt" - ], - "isUnique": false - } - }, - "foreignKeys": { - "core_notificationDeliveryAttempt_intentId_core_notificationIntent_id_fk": { - "name": "core_notificationDeliveryAttempt_intentId_core_notificationIntent_id_fk", - "tableFrom": "core_notificationDeliveryAttempt", - "tableTo": "core_notificationIntent", - "columnsFrom": [ - "intentId" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "core_notificationIntent": { - "name": "core_notificationIntent", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "recipientAuthId": { - "name": "recipientAuthId", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "payload": { - "name": "payload", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'{}'" - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'pending'" - }, - "idempotencyKey": { - "name": "idempotencyKey", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "createdAt": { - "name": "createdAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updatedAt": { - "name": "updatedAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "core_notificationIntent_idempotencyKey_unique": { - "name": "core_notificationIntent_idempotencyKey_unique", - "columns": [ - "idempotencyKey" - ], - "isUnique": true - }, - "core_notificationIntent_kind_idx": { - "name": "core_notificationIntent_kind_idx", - "columns": [ - "kind" - ], - "isUnique": false - }, - "core_notificationIntent_recipientAuthId_idx": { - "name": "core_notificationIntent_recipientAuthId_idx", - "columns": [ - "recipientAuthId" - ], - "isUnique": false - }, - "core_notificationIntent_status_idx": { - "name": "core_notificationIntent_status_idx", - "columns": [ - "status" - ], - "isUnique": false - }, - "core_notificationIntent_createdAt_idx": { - "name": "core_notificationIntent_createdAt_idx", - "columns": [ - "createdAt" - ], - "isUnique": false - } - }, - "foreignKeys": { - "core_notificationIntent_recipientAuthId_core_user_authId_fk": { - "name": "core_notificationIntent_recipientAuthId_core_user_authId_fk", - "tableFrom": "core_notificationIntent", - "tableTo": "core_user", - "columnsFrom": [ - "recipientAuthId" - ], - "columnsTo": [ - "authId" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "core_role": { - "name": "core_role", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "position": { - "name": "position", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "permissions": { - "name": "permissions", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - }, - "color": { - "name": "color", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "createdAt": { - "name": "createdAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updatedAt": { - "name": "updatedAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "core_role_name_unique": { - "name": "core_role_name_unique", - "columns": [ - "name" - ], - "isUnique": true - }, - "core_role_position_idx": { - "name": "core_role_position_idx", - "columns": [ - "position" - ], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "core_rsvp": { - "name": "core_rsvp", - "columns": { - "authId": { - "name": "authId", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "waitlistPosition": { - "name": "waitlistPosition", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "createdAt": { - "name": "createdAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updatedAt": { - "name": "updatedAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "confirmedAt": { - "name": "confirmedAt", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "waitlistedAt": { - "name": "waitlistedAt", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "cancelledAt": { - "name": "cancelledAt", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "cancelledByAuthId": { - "name": "cancelledByAuthId", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "promotedAt": { - "name": "promotedAt", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "promotedByAuthId": { - "name": "promotedByAuthId", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "core_rsvp_status_idx": { - "name": "core_rsvp_status_idx", - "columns": [ - "status" - ], - "isUnique": false - }, - "core_rsvp_waitlistPosition_idx": { - "name": "core_rsvp_waitlistPosition_idx", - "columns": [ - "waitlistPosition" - ], - "isUnique": false - }, - "core_rsvp_createdAt_idx": { - "name": "core_rsvp_createdAt_idx", - "columns": [ - "createdAt" - ], - "isUnique": false - }, - "core_rsvp_updatedAt_idx": { - "name": "core_rsvp_updatedAt_idx", - "columns": [ - "updatedAt" - ], - "isUnique": false - } - }, - "foreignKeys": { - "core_rsvp_authId_core_user_authId_fk": { - "name": "core_rsvp_authId_core_user_authId_fk", - "tableFrom": "core_rsvp", - "tableTo": "core_user", - "columnsFrom": [ - "authId" - ], - "columnsTo": [ - "authId" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "core_rsvp_cancelledByAuthId_core_user_authId_fk": { - "name": "core_rsvp_cancelledByAuthId_core_user_authId_fk", - "tableFrom": "core_rsvp", - "tableTo": "core_user", - "columnsFrom": [ - "cancelledByAuthId" - ], - "columnsTo": [ - "authId" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "core_rsvp_promotedByAuthId_core_user_authId_fk": { - "name": "core_rsvp_promotedByAuthId_core_user_authId_fk", - "tableFrom": "core_rsvp", - "tableTo": "core_user", - "columnsFrom": [ - "promotedByAuthId" - ], - "columnsTo": [ - "authId" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "core_setting": { - "name": "core_setting", - "columns": { - "key": { - "name": "key", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "createdAt": { - "name": "createdAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "createdByAuthId": { - "name": "createdByAuthId", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updatedAt": { - "name": "updatedAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updatedByAuthId": { - "name": "updatedByAuthId", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "core_setting_updatedAt_idx": { - "name": "core_setting_updatedAt_idx", - "columns": [ - "updatedAt" - ], - "isUnique": false - }, - "core_setting_updatedByAuthId_idx": { - "name": "core_setting_updatedByAuthId_idx", - "columns": [ - "updatedByAuthId" - ], - "isUnique": false - } - }, - "foreignKeys": { - "core_setting_createdByAuthId_core_user_authId_fk": { - "name": "core_setting_createdByAuthId_core_user_authId_fk", - "tableFrom": "core_setting", - "tableTo": "core_user", - "columnsFrom": [ - "createdByAuthId" - ], - "columnsTo": [ - "authId" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "core_setting_updatedByAuthId_core_user_authId_fk": { - "name": "core_setting_updatedByAuthId_core_user_authId_fk", - "tableFrom": "core_setting", - "tableTo": "core_user", - "columnsFrom": [ - "updatedByAuthId" - ], - "columnsTo": [ - "authId" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "core_user": { - "name": "core_user", - "columns": { - "authId": { - "name": "authId", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "firstName": { - "name": "firstName", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "lastName": { - "name": "lastName", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "profilePhotoUrl": { - "name": "profilePhotoUrl", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "hackTag": { - "name": "hackTag", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "bio": { - "name": "bio", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "pronouns": { - "name": "pronouns", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "skills": { - "name": "skills", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - }, - "isProfileSearchable": { - "name": "isProfileSearchable", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": true - }, - "discordDisplayHandle": { - "name": "discordDisplayHandle", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "roleId": { - "name": "roleId", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "isApproved": { - "name": "isApproved", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "checkedInAt": { - "name": "checkedInAt", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "createdAt": { - "name": "createdAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updatedAt": { - "name": "updatedAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "core_user_email_unique": { - "name": "core_user_email_unique", - "columns": [ - "email" - ], - "isUnique": true - }, - "core_user_hackTag_unique": { - "name": "core_user_hackTag_unique", - "columns": [ - "hackTag" - ], - "isUnique": true - }, - "core_user_email_idx": { - "name": "core_user_email_idx", - "columns": [ - "email" - ], - "isUnique": false - }, - "core_user_hackTag_idx": { - "name": "core_user_hackTag_idx", - "columns": [ - "hackTag" - ], - "isUnique": false - }, - "core_user_roleId_idx": { - "name": "core_user_roleId_idx", - "columns": [ - "roleId" - ], - "isUnique": false - }, - "core_user_createdAt_idx": { - "name": "core_user_createdAt_idx", - "columns": [ - "createdAt" - ], - "isUnique": false - } - }, - "foreignKeys": { - "core_user_roleId_core_role_id_fk": { - "name": "core_user_roleId_core_role_id_fk", - "tableFrom": "core_user", - "tableTo": "core_role", - "columnsFrom": [ - "roleId" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "core_userBan": { - "name": "core_userBan", - "columns": { - "authId": { - "name": "authId", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "reason": { - "name": "reason", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "bannedByAuthId": { - "name": "bannedByAuthId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "createdAt": { - "name": "createdAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "core_userBan_authId_core_user_authId_fk": { - "name": "core_userBan_authId_core_user_authId_fk", - "tableFrom": "core_userBan", - "tableTo": "core_user", - "columnsFrom": [ - "authId" - ], - "columnsTo": [ - "authId" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "core_userBan_bannedByAuthId_core_user_authId_fk": { - "name": "core_userBan_bannedByAuthId_core_user_authId_fk", - "tableFrom": "core_userBan", - "tableTo": "core_user", - "columnsFrom": [ - "bannedByAuthId" - ], - "columnsTo": [ - "authId" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "core_userData": { - "name": "core_userData", - "columns": { - "authId": { - "name": "authId", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "age": { - "name": "age", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "gender": { - "name": "gender", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "race": { - "name": "race", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "ethnicity": { - "name": "ethnicity", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "shirtSize": { - "name": "shirtSize", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "dietaryRestrictions": { - "name": "dietaryRestrictions", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - }, - "accommodationNote": { - "name": "accommodationNote", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "phoneNumber": { - "name": "phoneNumber", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "countryOfResidence": { - "name": "countryOfResidence", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "hasAcceptedMLHCodeOfConduct": { - "name": "hasAcceptedMLHCodeOfConduct", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "hasSharedDataWithMLH": { - "name": "hasSharedDataWithMLH", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "isEmailable": { - "name": "isEmailable", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "completedAt": { - "name": "completedAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updatedAt": { - "name": "updatedAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "core_userData_authId_core_user_authId_fk": { - "name": "core_userData_authId_core_user_authId_fk", - "tableFrom": "core_userData", - "tableTo": "core_user", - "columnsFrom": [ - "authId" - ], - "columnsTo": [ - "authId" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "discord_member": { - "name": "discord_member", - "columns": { - "authId": { - "name": "authId", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "discordUserId": { - "name": "discordUserId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "guildId": { - "name": "guildId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "username": { - "name": "username", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "avatarHash": { - "name": "avatarHash", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "verifiedAt": { - "name": "verifiedAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updatedAt": { - "name": "updatedAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "lastRoleSyncAt": { - "name": "lastRoleSyncAt", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "discord_member_discordUserId_unique": { - "name": "discord_member_discordUserId_unique", - "columns": [ - "discordUserId" - ], - "isUnique": true - }, - "discord_member_discordUserId_idx": { - "name": "discord_member_discordUserId_idx", - "columns": [ - "discordUserId" - ], - "isUnique": false - }, - "discord_member_guildId_idx": { - "name": "discord_member_guildId_idx", - "columns": [ - "guildId" - ], - "isUnique": false - }, - "discord_member_updatedAt_idx": { - "name": "discord_member_updatedAt_idx", - "columns": [ - "updatedAt" - ], - "isUnique": false - } - }, - "foreignKeys": { - "discord_member_authId_core_user_authId_fk": { - "name": "discord_member_authId_core_user_authId_fk", - "tableFrom": "discord_member", - "tableTo": "core_user", - "columnsFrom": [ - "authId" - ], - "columnsTo": [ - "authId" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "discord_roleSyncAttempt": { - "name": "discord_roleSyncAttempt", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "authId": { - "name": "authId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "discordUserId": { - "name": "discordUserId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "guildId": { - "name": "guildId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "roleIds": { - "name": "roleIds", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - }, - "roleNames": { - "name": "roleNames", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - }, - "error": { - "name": "error", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "createdAt": { - "name": "createdAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "discord_roleSyncAttempt_authId_idx": { - "name": "discord_roleSyncAttempt_authId_idx", - "columns": [ - "authId" - ], - "isUnique": false - }, - "discord_roleSyncAttempt_discordUserId_idx": { - "name": "discord_roleSyncAttempt_discordUserId_idx", - "columns": [ - "discordUserId" - ], - "isUnique": false - }, - "discord_roleSyncAttempt_status_idx": { - "name": "discord_roleSyncAttempt_status_idx", - "columns": [ - "status" - ], - "isUnique": false - }, - "discord_roleSyncAttempt_createdAt_idx": { - "name": "discord_roleSyncAttempt_createdAt_idx", - "columns": [ - "createdAt" - ], - "isUnique": false - } - }, - "foreignKeys": { - "discord_roleSyncAttempt_authId_core_user_authId_fk": { - "name": "discord_roleSyncAttempt_authId_core_user_authId_fk", - "tableFrom": "discord_roleSyncAttempt", - "tableTo": "core_user", - "columnsFrom": [ - "authId" - ], - "columnsTo": [ - "authId" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "discord_verification": { - "name": "discord_verification", - "columns": { - "code": { - "name": "code", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "discordUserId": { - "name": "discordUserId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "guildId": { - "name": "guildId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "username": { - "name": "username", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "avatarHash": { - "name": "avatarHash", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "authId": { - "name": "authId", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'pending'" - }, - "expiresAt": { - "name": "expiresAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "createdAt": { - "name": "createdAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "acceptedAt": { - "name": "acceptedAt", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "discord_verification_discordUserId_idx": { - "name": "discord_verification_discordUserId_idx", - "columns": [ - "discordUserId" - ], - "isUnique": false - }, - "discord_verification_guildId_idx": { - "name": "discord_verification_guildId_idx", - "columns": [ - "guildId" - ], - "isUnique": false - }, - "discord_verification_authId_idx": { - "name": "discord_verification_authId_idx", - "columns": [ - "authId" - ], - "isUnique": false - }, - "discord_verification_status_idx": { - "name": "discord_verification_status_idx", - "columns": [ - "status" - ], - "isUnique": false - }, - "discord_verification_createdAt_idx": { - "name": "discord_verification_createdAt_idx", - "columns": [ - "createdAt" - ], - "isUnique": false - } - }, - "foreignKeys": { - "discord_verification_authId_core_user_authId_fk": { - "name": "discord_verification_authId_core_user_authId_fk", - "tableFrom": "discord_verification", - "tableTo": "core_user", - "columnsFrom": [ - "authId" - ], - "columnsTo": [ - "authId" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "teams_invite": { - "name": "teams_invite", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "teamId": { - "name": "teamId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "inviteeAuthId": { - "name": "inviteeAuthId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'pending'" - }, - "createdAt": { - "name": "createdAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "teams_invite_teamId_idx": { - "name": "teams_invite_teamId_idx", - "columns": [ - "teamId" - ], - "isUnique": false - }, - "teams_invite_inviteeAuthId_idx": { - "name": "teams_invite_inviteeAuthId_idx", - "columns": [ - "inviteeAuthId" - ], - "isUnique": false - }, - "teams_invite_teamId_inviteeAuthId_idx": { - "name": "teams_invite_teamId_inviteeAuthId_idx", - "columns": [ - "teamId", - "inviteeAuthId" - ], - "isUnique": false - } - }, - "foreignKeys": { - "teams_invite_teamId_teams_team_id_fk": { - "name": "teams_invite_teamId_teams_team_id_fk", - "tableFrom": "teams_invite", - "tableTo": "teams_team", - "columnsFrom": [ - "teamId" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "teams_invite_inviteeAuthId_core_user_authId_fk": { - "name": "teams_invite_inviteeAuthId_core_user_authId_fk", - "tableFrom": "teams_invite", - "tableTo": "core_user", - "columnsFrom": [ - "inviteeAuthId" - ], - "columnsTo": [ - "authId" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "teams_member": { - "name": "teams_member", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "teamId": { - "name": "teamId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "authId": { - "name": "authId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "joinedAt": { - "name": "joinedAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "teams_member_authId_unique": { - "name": "teams_member_authId_unique", - "columns": [ - "authId" - ], - "isUnique": true - }, - "teams_member_teamId_idx": { - "name": "teams_member_teamId_idx", - "columns": [ - "teamId" - ], - "isUnique": false - }, - "teams_member_authId_idx": { - "name": "teams_member_authId_idx", - "columns": [ - "authId" - ], - "isUnique": false - } - }, - "foreignKeys": { - "teams_member_teamId_teams_team_id_fk": { - "name": "teams_member_teamId_teams_team_id_fk", - "tableFrom": "teams_member", - "tableTo": "teams_team", - "columnsFrom": [ - "teamId" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "teams_member_authId_core_user_authId_fk": { - "name": "teams_member_authId_core_user_authId_fk", - "tableFrom": "teams_member", - "tableTo": "core_user", - "columnsFrom": [ - "authId" - ], - "columnsTo": [ - "authId" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "teams_team": { - "name": "teams_team", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "tag": { - "name": "tag", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "ownerAuthId": { - "name": "ownerAuthId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "createdAt": { - "name": "createdAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "teams_team_tag_unique": { - "name": "teams_team_tag_unique", - "columns": [ - "tag" - ], - "isUnique": true - }, - "teams_team_tag_idx": { - "name": "teams_team_tag_idx", - "columns": [ - "tag" - ], - "isUnique": false - }, - "teams_team_ownerAuthId_idx": { - "name": "teams_team_ownerAuthId_idx", - "columns": [ - "ownerAuthId" - ], - "isUnique": false - } - }, - "foreignKeys": { - "teams_team_ownerAuthId_core_user_authId_fk": { - "name": "teams_team_ownerAuthId_core_user_authId_fk", - "tableFrom": "teams_team", - "tableTo": "core_user", - "columnsFrom": [ - "ownerAuthId" - ], - "columnsTo": [ - "authId" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} \ No newline at end of file diff --git a/apps/web/db/migrations/meta/_journal.json b/apps/web/db/migrations/meta/_journal.json deleted file mode 100644 index 557ba34f..00000000 --- a/apps/web/db/migrations/meta/_journal.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": "7", - "dialect": "sqlite", - "entries": [ - { - "idx": 0, - "version": "6", - "when": 1784083199149, - "tag": "0000_init", - "breakpoints": true - } - ] -} diff --git a/apps/web/db/migrations/mysql/.snapshot.json b/apps/web/db/migrations/mysql/.snapshot.json new file mode 100644 index 00000000..e5153b6a --- /dev/null +++ b/apps/web/db/migrations/mysql/.snapshot.json @@ -0,0 +1,4619 @@ +{ + "namespaces": [], + "tables": [ + { + "name": "account", + "columns": { + "access_token": { + "name": "access_token", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "account_id": { + "name": "account_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "id": { + "name": "id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "id_token": { + "name": "id_token", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "password": { + "name": "password", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "scope": { + "name": "scope", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + } + }, + "indexes": [ + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "PRIMARY", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "user_id" + ], + "composite": false, + "constraint": false, + "keyName": "account_user_id_index", + "primary": false, + "unique": false + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "account_user_id_foreign": { + "columnNames": [ + "user_id" + ], + "constraintName": "account_user_id_foreign", + "localTableName": "account", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "user", + "deleteRule": "cascade" + } + }, + "comment": null + }, + { + "name": "core_event", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "description": { + "name": "description", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "end_time": { + "name": "end_time", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "hidden": { + "name": "hidden", + "type": "tinyint(1)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "false", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "boolean" + }, + "host": { + "name": "host", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "id": { + "name": "id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "location": { + "name": "location", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": "'TBD'", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "start_time": { + "name": "start_time", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "title": { + "name": "title", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "type": { + "name": "type", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "indexes": [ + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "PRIMARY", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "hidden" + ], + "composite": false, + "constraint": false, + "keyName": "core_event_hidden_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "start_time" + ], + "composite": false, + "constraint": false, + "keyName": "core_event_start_time_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "type" + ], + "composite": false, + "constraint": false, + "keyName": "core_event_type_index", + "primary": false, + "unique": false + } + ], + "checks": [], + "triggers": [], + "foreignKeys": {}, + "comment": null + }, + { + "name": "core_event_scan", + "columns": { + "auth_id": { + "name": "auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "event_id": { + "name": "event_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "id": { + "name": "id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "scanned_at": { + "name": "scanned_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "scanned_by_auth_id": { + "name": "scanned_by_auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + } + }, + "indexes": [ + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "PRIMARY", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_event_scan_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "event_id", + "auth_id" + ], + "composite": true, + "constraint": false, + "keyName": "core_event_scan_event_id_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "event_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_event_scan_event_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "scanned_at" + ], + "composite": false, + "constraint": false, + "keyName": "core_event_scan_scanned_at_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "scanned_by_auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_event_scan_scanned_by_auth_id_index", + "primary": false, + "unique": false + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "core_event_scan_auth_id_foreign": { + "columnNames": [ + "auth_id" + ], + "constraintName": "core_event_scan_auth_id_foreign", + "localTableName": "core_event_scan", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user", + "deleteRule": "cascade" + }, + "core_event_scan_event_id_foreign": { + "columnNames": [ + "event_id" + ], + "constraintName": "core_event_scan_event_id_foreign", + "localTableName": "core_event_scan", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "core_event", + "deleteRule": "cascade" + }, + "core_event_scan_scanned_by_auth_id_foreign": { + "columnNames": [ + "scanned_by_auth_id" + ], + "constraintName": "core_event_scan_scanned_by_auth_id_foreign", + "localTableName": "core_event_scan", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user" + } + }, + "comment": null + }, + { + "name": "core_hacker", + "columns": { + "auth_id": { + "name": "auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "github_url": { + "name": "github_url", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "group": { + "name": "group", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "hackathons_attended": { + "name": "hackathons_attended", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "integer" + }, + "heard_from": { + "name": "heard_from", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "level_of_study": { + "name": "level_of_study", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "linked_in_url": { + "name": "linked_in_url", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "major": { + "name": "major", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "personal_website_url": { + "name": "personal_website_url", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "registered_at": { + "name": "registered_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "resume_url": { + "name": "resume_url", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "school_id": { + "name": "school_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "software_experience": { + "name": "software_experience", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "university": { + "name": "university", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "indexes": [ + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": true, + "keyName": "PRIMARY", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_hacker_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "registered_at" + ], + "composite": false, + "constraint": false, + "keyName": "core_hacker_registered_at_index", + "primary": false, + "unique": false + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "core_hacker_auth_id_foreign": { + "columnNames": [ + "auth_id" + ], + "constraintName": "core_hacker_auth_id_foreign", + "localTableName": "core_hacker", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user", + "updateRule": "cascade", + "deleteRule": "cascade" + } + }, + "comment": null + }, + { + "name": "core_notification_delivery_attempt", + "columns": { + "attempted_at": { + "name": "attempted_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "channel": { + "name": "channel", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "error": { + "name": "error", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "id": { + "name": "id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "intent_id": { + "name": "intent_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "metadata": { + "name": "metadata", + "type": "json", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "json" + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "recipient": { + "name": "recipient", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "status": { + "name": "status", + "type": "enum('delivered','failed','skipped')", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [ + "delivered", + "failed", + "skipped" + ], + "mappedType": "enum" + } + }, + "indexes": [ + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "PRIMARY", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "attempted_at" + ], + "composite": false, + "constraint": false, + "keyName": "core_notification_delivery_attempt_attempted_at_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "channel" + ], + "composite": false, + "constraint": false, + "keyName": "core_notification_delivery_attempt_channel_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "intent_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_notification_delivery_attempt_intent_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "status" + ], + "composite": false, + "constraint": false, + "keyName": "core_notification_delivery_attempt_status_index", + "primary": false, + "unique": false + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "core_notification_delivery_attempt_intent_id_foreign": { + "columnNames": [ + "intent_id" + ], + "constraintName": "core_notification_delivery_attempt_intent_id_foreign", + "localTableName": "core_notification_delivery_attempt", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "core_notification_intent", + "deleteRule": "cascade" + } + }, + "comment": null + }, + { + "name": "core_notification_intent", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "id": { + "name": "id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": true, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "kind": { + "name": "kind", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "payload": { + "name": "payload", + "type": "json", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "json" + }, + "recipient_auth_id": { + "name": "recipient_auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "status": { + "name": "status", + "type": "enum('pending','processing','delivered','failed','skipped')", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "'pending'", + "comment": null, + "collation": null, + "enumItems": [ + "pending", + "processing", + "delivered", + "failed", + "skipped" + ], + "mappedType": "enum" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "indexes": [ + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "PRIMARY", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "created_at" + ], + "composite": false, + "constraint": false, + "keyName": "core_notification_intent_created_at_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "idempotency_key" + ], + "composite": false, + "constraint": true, + "keyName": "core_notification_intent_idempotency_key_unique", + "primary": false, + "unique": true + }, + { + "columnNames": [ + "kind" + ], + "composite": false, + "constraint": false, + "keyName": "core_notification_intent_kind_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "recipient_auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_notification_intent_recipient_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "status" + ], + "composite": false, + "constraint": false, + "keyName": "core_notification_intent_status_index", + "primary": false, + "unique": false + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "core_notification_intent_recipient_auth_id_foreign": { + "columnNames": [ + "recipient_auth_id" + ], + "constraintName": "core_notification_intent_recipient_auth_id_foreign", + "localTableName": "core_notification_intent", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user", + "deleteRule": "set null" + } + }, + "comment": null + }, + { + "name": "core_operation_lock", + "columns": { + "key": { + "name": "key", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "token": { + "name": "token", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + } + }, + "indexes": [ + { + "columnNames": [ + "key" + ], + "composite": false, + "constraint": true, + "keyName": "PRIMARY", + "primary": true, + "unique": true + } + ], + "checks": [], + "triggers": [], + "foreignKeys": {}, + "comment": null + }, + { + "name": "core_role", + "columns": { + "color": { + "name": "color", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "id": { + "name": "id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": true, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "permissions": { + "name": "permissions", + "type": "json", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "'[]'", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "json" + }, + "position": { + "name": "position", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "integer" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "indexes": [ + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "PRIMARY", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "name" + ], + "composite": false, + "constraint": true, + "keyName": "core_role_name_unique", + "primary": false, + "unique": true + }, + { + "columnNames": [ + "position" + ], + "composite": false, + "constraint": false, + "keyName": "core_role_position_index", + "primary": false, + "unique": false + } + ], + "checks": [], + "triggers": [], + "foreignKeys": {}, + "comment": null + }, + { + "name": "core_rsvp", + "columns": { + "auth_id": { + "name": "auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "cancelled_by_auth_id": { + "name": "cancelled_by_auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "promoted_at": { + "name": "promoted_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "promoted_by_auth_id": { + "name": "promoted_by_auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "status": { + "name": "status", + "type": "enum('confirmed','waitlisted','cancelled')", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [ + "confirmed", + "waitlisted", + "cancelled" + ], + "mappedType": "enum" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "waitlist_position": { + "name": "waitlist_position", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "integer" + }, + "waitlisted_at": { + "name": "waitlisted_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "indexes": [ + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": true, + "keyName": "PRIMARY", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_rsvp_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "cancelled_by_auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_rsvp_cancelled_by_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "created_at" + ], + "composite": false, + "constraint": false, + "keyName": "core_rsvp_created_at_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "promoted_by_auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_rsvp_promoted_by_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "status" + ], + "composite": false, + "constraint": false, + "keyName": "core_rsvp_status_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "updated_at" + ], + "composite": false, + "constraint": false, + "keyName": "core_rsvp_updated_at_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "waitlist_position" + ], + "composite": false, + "constraint": false, + "keyName": "core_rsvp_waitlist_position_index", + "primary": false, + "unique": false + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "core_rsvp_auth_id_foreign": { + "columnNames": [ + "auth_id" + ], + "constraintName": "core_rsvp_auth_id_foreign", + "localTableName": "core_rsvp", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user", + "updateRule": "cascade", + "deleteRule": "cascade" + }, + "core_rsvp_cancelled_by_auth_id_foreign": { + "columnNames": [ + "cancelled_by_auth_id" + ], + "constraintName": "core_rsvp_cancelled_by_auth_id_foreign", + "localTableName": "core_rsvp", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user", + "deleteRule": "set null" + }, + "core_rsvp_promoted_by_auth_id_foreign": { + "columnNames": [ + "promoted_by_auth_id" + ], + "constraintName": "core_rsvp_promoted_by_auth_id_foreign", + "localTableName": "core_rsvp", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user", + "deleteRule": "set null" + } + }, + "comment": null + }, + { + "name": "core_setting", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "created_by_auth_id": { + "name": "created_by_auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "key": { + "name": "key", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "updated_by_auth_id": { + "name": "updated_by_auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "value": { + "name": "value", + "type": "json", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "json" + } + }, + "indexes": [ + { + "columnNames": [ + "key" + ], + "composite": false, + "constraint": true, + "keyName": "PRIMARY", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "created_by_auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_setting_created_by_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "updated_at" + ], + "composite": false, + "constraint": false, + "keyName": "core_setting_updated_at_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "updated_by_auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_setting_updated_by_auth_id_index", + "primary": false, + "unique": false + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "core_setting_created_by_auth_id_foreign": { + "columnNames": [ + "created_by_auth_id" + ], + "constraintName": "core_setting_created_by_auth_id_foreign", + "localTableName": "core_setting", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user", + "deleteRule": "set null" + }, + "core_setting_updated_by_auth_id_foreign": { + "columnNames": [ + "updated_by_auth_id" + ], + "constraintName": "core_setting_updated_by_auth_id_foreign", + "localTableName": "core_setting", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user", + "deleteRule": "set null" + } + }, + "comment": null + }, + { + "name": "core_user", + "columns": { + "auth_id": { + "name": "auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "bio": { + "name": "bio", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "checked_in_at": { + "name": "checked_in_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "discord_display_handle": { + "name": "discord_display_handle", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "first_name": { + "name": "first_name", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "hack_tag": { + "name": "hack_tag", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": true, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "is_approved": { + "name": "is_approved", + "type": "tinyint(1)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "false", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "boolean" + }, + "is_profile_searchable": { + "name": "is_profile_searchable", + "type": "tinyint(1)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "true", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "boolean" + }, + "last_name": { + "name": "last_name", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "profile_photo_url": { + "name": "profile_photo_url", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "pronouns": { + "name": "pronouns", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "role_id": { + "name": "role_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "skills": { + "name": "skills", + "type": "json", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "'[]'", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "json" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "indexes": [ + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": true, + "keyName": "PRIMARY", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "created_at" + ], + "composite": false, + "constraint": false, + "keyName": "core_user_created_at_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "hack_tag" + ], + "composite": false, + "constraint": false, + "keyName": "core_user_hack_tag_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "hack_tag" + ], + "composite": false, + "constraint": true, + "keyName": "core_user_hack_tag_unique", + "primary": false, + "unique": true + }, + { + "columnNames": [ + "role_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_user_role_id_index", + "primary": false, + "unique": false + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "core_user_auth_id_foreign": { + "columnNames": [ + "auth_id" + ], + "constraintName": "core_user_auth_id_foreign", + "localTableName": "core_user", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "user", + "updateRule": "cascade", + "deleteRule": "cascade" + }, + "core_user_role_id_foreign": { + "columnNames": [ + "role_id" + ], + "constraintName": "core_user_role_id_foreign", + "localTableName": "core_user", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "core_role", + "deleteRule": "set null" + } + }, + "comment": null + }, + { + "name": "core_user_ban", + "columns": { + "auth_id": { + "name": "auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "banned_by_auth_id": { + "name": "banned_by_auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "reason": { + "name": "reason", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + } + }, + "indexes": [ + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": true, + "keyName": "PRIMARY", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_user_ban_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "banned_by_auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_user_ban_banned_by_auth_id_index", + "primary": false, + "unique": false + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "core_user_ban_auth_id_foreign": { + "columnNames": [ + "auth_id" + ], + "constraintName": "core_user_ban_auth_id_foreign", + "localTableName": "core_user_ban", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user", + "updateRule": "cascade", + "deleteRule": "cascade" + }, + "core_user_ban_banned_by_auth_id_foreign": { + "columnNames": [ + "banned_by_auth_id" + ], + "constraintName": "core_user_ban_banned_by_auth_id_foreign", + "localTableName": "core_user_ban", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user" + } + }, + "comment": null + }, + { + "name": "core_user_data", + "columns": { + "accommodation_note": { + "name": "accommodation_note", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "age": { + "name": "age", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "integer" + }, + "auth_id": { + "name": "auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "completed_at": { + "name": "completed_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "country_of_residence": { + "name": "country_of_residence", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "dietary_restrictions": { + "name": "dietary_restrictions", + "type": "json", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "'[]'", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "json" + }, + "ethnicity": { + "name": "ethnicity", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "gender": { + "name": "gender", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "has_accepted_mlhcode_of_conduct": { + "name": "has_accepted_mlhcode_of_conduct", + "type": "tinyint(1)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "boolean" + }, + "has_shared_data_with_mlh": { + "name": "has_shared_data_with_mlh", + "type": "tinyint(1)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "boolean" + }, + "is_emailable": { + "name": "is_emailable", + "type": "tinyint(1)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "boolean" + }, + "phone_number": { + "name": "phone_number", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "race": { + "name": "race", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "shirt_size": { + "name": "shirt_size", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "indexes": [ + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": true, + "keyName": "PRIMARY", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_user_data_auth_id_index", + "primary": false, + "unique": false + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "core_user_data_auth_id_foreign": { + "columnNames": [ + "auth_id" + ], + "constraintName": "core_user_data_auth_id_foreign", + "localTableName": "core_user_data", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user", + "updateRule": "cascade", + "deleteRule": "cascade" + } + }, + "comment": null + }, + { + "name": "discord_member", + "columns": { + "auth_id": { + "name": "auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "avatar_hash": { + "name": "avatar_hash", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "discord_user_id": { + "name": "discord_user_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": true, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "guild_id": { + "name": "guild_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "last_role_sync_at": { + "name": "last_role_sync_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "username": { + "name": "username", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "verified_at": { + "name": "verified_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "indexes": [ + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": true, + "keyName": "PRIMARY", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "discord_member_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "discord_user_id" + ], + "composite": false, + "constraint": false, + "keyName": "discord_member_discord_user_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "discord_user_id" + ], + "composite": false, + "constraint": true, + "keyName": "discord_member_discord_user_id_unique", + "primary": false, + "unique": true + }, + { + "columnNames": [ + "guild_id" + ], + "composite": false, + "constraint": false, + "keyName": "discord_member_guild_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "updated_at" + ], + "composite": false, + "constraint": false, + "keyName": "discord_member_updated_at_index", + "primary": false, + "unique": false + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "discord_member_auth_id_foreign": { + "columnNames": [ + "auth_id" + ], + "constraintName": "discord_member_auth_id_foreign", + "localTableName": "discord_member", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user", + "updateRule": "cascade", + "deleteRule": "cascade" + } + }, + "comment": null + }, + { + "name": "discord_role_sync_attempt", + "columns": { + "auth_id": { + "name": "auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "discord_user_id": { + "name": "discord_user_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "error": { + "name": "error", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "guild_id": { + "name": "guild_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "id": { + "name": "id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "role_ids": { + "name": "role_ids", + "type": "json", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "'[]'", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "json" + }, + "role_names": { + "name": "role_names", + "type": "json", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "'[]'", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "json" + }, + "status": { + "name": "status", + "type": "enum('synced','failed','skipped')", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [ + "synced", + "failed", + "skipped" + ], + "mappedType": "enum" + } + }, + "indexes": [ + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "PRIMARY", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "discord_role_sync_attempt_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "created_at" + ], + "composite": false, + "constraint": false, + "keyName": "discord_role_sync_attempt_created_at_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "discord_user_id" + ], + "composite": false, + "constraint": false, + "keyName": "discord_role_sync_attempt_discord_user_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "status" + ], + "composite": false, + "constraint": false, + "keyName": "discord_role_sync_attempt_status_index", + "primary": false, + "unique": false + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "discord_role_sync_attempt_auth_id_foreign": { + "columnNames": [ + "auth_id" + ], + "constraintName": "discord_role_sync_attempt_auth_id_foreign", + "localTableName": "discord_role_sync_attempt", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user", + "deleteRule": "cascade" + } + }, + "comment": null + }, + { + "name": "discord_verification", + "columns": { + "accepted_at": { + "name": "accepted_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "auth_id": { + "name": "auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "avatar_hash": { + "name": "avatar_hash", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "code": { + "name": "code", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "discord_user_id": { + "name": "discord_user_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "guild_id": { + "name": "guild_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "status": { + "name": "status", + "type": "enum('pending','accepted','rejected','expired')", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "'pending'", + "comment": null, + "collation": null, + "enumItems": [ + "pending", + "accepted", + "rejected", + "expired" + ], + "mappedType": "enum" + }, + "username": { + "name": "username", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + } + }, + "indexes": [ + { + "columnNames": [ + "code" + ], + "composite": false, + "constraint": true, + "keyName": "PRIMARY", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "discord_verification_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "created_at" + ], + "composite": false, + "constraint": false, + "keyName": "discord_verification_created_at_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "discord_user_id" + ], + "composite": false, + "constraint": false, + "keyName": "discord_verification_discord_user_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "guild_id" + ], + "composite": false, + "constraint": false, + "keyName": "discord_verification_guild_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "status" + ], + "composite": false, + "constraint": false, + "keyName": "discord_verification_status_index", + "primary": false, + "unique": false + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "discord_verification_auth_id_foreign": { + "columnNames": [ + "auth_id" + ], + "constraintName": "discord_verification_auth_id_foreign", + "localTableName": "discord_verification", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user", + "deleteRule": "set null" + } + }, + "comment": null + }, + { + "name": "session", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "id": { + "name": "id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "token": { + "name": "token", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": true, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + } + }, + "indexes": [ + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "PRIMARY", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "token" + ], + "composite": false, + "constraint": true, + "keyName": "session_token_unique", + "primary": false, + "unique": true + }, + { + "columnNames": [ + "user_id" + ], + "composite": false, + "constraint": false, + "keyName": "session_user_id_index", + "primary": false, + "unique": false + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "session_user_id_foreign": { + "columnNames": [ + "user_id" + ], + "constraintName": "session_user_id_foreign", + "localTableName": "session", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "user", + "deleteRule": "cascade" + } + }, + "comment": null + }, + { + "name": "teams_invite", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "id": { + "name": "id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "invitee_auth_id": { + "name": "invitee_auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "status": { + "name": "status", + "type": "enum('pending','accepted','declined')", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "'pending'", + "comment": null, + "collation": null, + "enumItems": [ + "pending", + "accepted", + "declined" + ], + "mappedType": "enum" + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + } + }, + "indexes": [ + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "PRIMARY", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "invitee_auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "teams_invite_invitee_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "team_id" + ], + "composite": false, + "constraint": false, + "keyName": "teams_invite_team_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "team_id", + "invitee_auth_id" + ], + "composite": true, + "constraint": false, + "keyName": "teams_invite_team_id_invitee_auth_id_index", + "primary": false, + "unique": false + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "teams_invite_invitee_auth_id_foreign": { + "columnNames": [ + "invitee_auth_id" + ], + "constraintName": "teams_invite_invitee_auth_id_foreign", + "localTableName": "teams_invite", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user", + "deleteRule": "cascade" + }, + "teams_invite_team_id_foreign": { + "columnNames": [ + "team_id" + ], + "constraintName": "teams_invite_team_id_foreign", + "localTableName": "teams_invite", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "teams_team", + "deleteRule": "cascade" + } + }, + "comment": null + }, + { + "name": "teams_member", + "columns": { + "auth_id": { + "name": "auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": true, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "id": { + "name": "id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "joined_at": { + "name": "joined_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + } + }, + "indexes": [ + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "PRIMARY", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "teams_member_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": true, + "keyName": "teams_member_auth_id_unique", + "primary": false, + "unique": true + }, + { + "columnNames": [ + "team_id" + ], + "composite": false, + "constraint": false, + "keyName": "teams_member_team_id_index", + "primary": false, + "unique": false + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "teams_member_auth_id_foreign": { + "columnNames": [ + "auth_id" + ], + "constraintName": "teams_member_auth_id_foreign", + "localTableName": "teams_member", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user", + "deleteRule": "cascade" + }, + "teams_member_team_id_foreign": { + "columnNames": [ + "team_id" + ], + "constraintName": "teams_member_team_id_foreign", + "localTableName": "teams_member", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "teams_team", + "deleteRule": "cascade" + } + }, + "comment": null + }, + { + "name": "teams_team", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "id": { + "name": "id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "owner_auth_id": { + "name": "owner_auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "tag": { + "name": "tag", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": true, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + } + }, + "indexes": [ + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "PRIMARY", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "owner_auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "teams_team_owner_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "tag" + ], + "composite": false, + "constraint": false, + "keyName": "teams_team_tag_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "tag" + ], + "composite": false, + "constraint": true, + "keyName": "teams_team_tag_unique", + "primary": false, + "unique": true + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "teams_team_owner_auth_id_foreign": { + "columnNames": [ + "owner_auth_id" + ], + "constraintName": "teams_team_owner_auth_id_foreign", + "localTableName": "teams_team", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user", + "deleteRule": "cascade" + } + }, + "comment": null + }, + { + "name": "user", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": true, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "email_verified": { + "name": "email_verified", + "type": "tinyint(1)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "false", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "boolean" + }, + "id": { + "name": "id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "image": { + "name": "image", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "indexes": [ + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "PRIMARY", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "email" + ], + "composite": false, + "constraint": true, + "keyName": "user_email_unique", + "primary": false, + "unique": true + } + ], + "checks": [], + "triggers": [], + "foreignKeys": {}, + "comment": null + }, + { + "name": "verification", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "id": { + "name": "id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "value": { + "name": "value", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + } + }, + "indexes": [ + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "PRIMARY", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "identifier" + ], + "composite": false, + "constraint": false, + "keyName": "verification_identifier_index", + "primary": false, + "unique": false + } + ], + "checks": [], + "triggers": [], + "foreignKeys": {}, + "comment": null + } + ], + "views": [], + "nativeEnums": {} +} \ No newline at end of file diff --git a/apps/web/db/migrations/mysql/Migration20260927180400_initial.ts b/apps/web/db/migrations/mysql/Migration20260927180400_initial.ts new file mode 100644 index 00000000..d6a3e6b9 --- /dev/null +++ b/apps/web/db/migrations/mysql/Migration20260927180400_initial.ts @@ -0,0 +1,214 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20260927180400_initial extends Migration { + + override name = 'Migration20260927180400_initial'; + + override up(): void | Promise { + this.addSql(`create table \`user\` (\`id\` varchar(255) not null, \`name\` varchar(255) not null, \`email\` varchar(255) not null, \`email_verified\` tinyint(1) not null default false, \`image\` text null, \`created_at\` datetime not null, \`updated_at\` datetime not null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`); + this.addSql(`alter table \`user\` add unique \`user_email_unique\` (\`email\`);`); + + this.addSql(`create table \`session\` (\`id\` varchar(255) not null, \`expires_at\` datetime not null, \`token\` varchar(255) not null, \`created_at\` datetime not null, \`updated_at\` datetime not null, \`ip_address\` varchar(255) null, \`user_agent\` text null, \`user_id\` varchar(255) not null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`); + this.addSql(`alter table \`session\` add unique \`session_token_unique\` (\`token\`);`); + this.addSql(`alter table \`session\` add index \`session_user_id_index\` (\`user_id\`);`); + + this.addSql(`create table \`account\` (\`id\` varchar(255) not null, \`account_id\` varchar(255) not null, \`provider_id\` varchar(255) not null, \`user_id\` varchar(255) not null, \`access_token\` text null, \`refresh_token\` text null, \`id_token\` text null, \`access_token_expires_at\` datetime null, \`refresh_token_expires_at\` datetime null, \`scope\` text null, \`password\` varchar(255) null, \`created_at\` datetime not null, \`updated_at\` datetime not null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`); + this.addSql(`alter table \`account\` add index \`account_user_id_index\` (\`user_id\`);`); + + this.addSql(`create table \`verification\` (\`id\` varchar(255) not null, \`identifier\` varchar(255) not null, \`value\` text not null, \`expires_at\` datetime not null, \`created_at\` datetime null, \`updated_at\` datetime null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`); + this.addSql(`alter table \`verification\` add index \`verification_identifier_index\` (\`identifier\`);`); + + this.addSql(`create table \`core_event\` (\`id\` varchar(255) not null, \`title\` varchar(255) not null, \`start_time\` datetime not null, \`end_time\` datetime not null, \`location\` varchar(255) not null default 'TBD', \`description\` text not null, \`type\` varchar(255) not null, \`host\` varchar(255) null, \`hidden\` tinyint(1) not null default false, \`created_at\` datetime not null, \`updated_at\` datetime not null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`); + this.addSql(`alter table \`core_event\` add index \`core_event_start_time_index\` (\`start_time\`);`); + this.addSql(`alter table \`core_event\` add index \`core_event_type_index\` (\`type\`);`); + this.addSql(`alter table \`core_event\` add index \`core_event_hidden_index\` (\`hidden\`);`); + + this.addSql(`create table \`core_operation_lock\` (\`key\` varchar(255) not null, \`token\` varchar(255) not null, primary key (\`key\`)) default character set utf8mb4 engine = InnoDB;`); + + this.addSql(`create table \`core_role\` (\`id\` varchar(255) not null, \`name\` varchar(255) not null, \`position\` int not null, \`permissions\` json not null default ('[]'), \`color\` varchar(255) null, \`created_at\` datetime not null, \`updated_at\` datetime not null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`); + this.addSql(`alter table \`core_role\` add unique \`core_role_name_unique\` (\`name\`);`); + this.addSql(`alter table \`core_role\` add index \`core_role_position_index\` (\`position\`);`); + + this.addSql(`create table \`core_user\` (\`auth_id\` varchar(255) not null, \`first_name\` varchar(255) not null, \`last_name\` varchar(255) not null, \`profile_photo_url\` text null, \`hack_tag\` varchar(255) null, \`bio\` text null, \`pronouns\` varchar(255) null, \`skills\` json not null default ('[]'), \`is_profile_searchable\` tinyint(1) not null default true, \`discord_display_handle\` varchar(255) null, \`role_id\` varchar(255) null, \`is_approved\` tinyint(1) not null default false, \`checked_in_at\` datetime null, \`created_at\` datetime not null, \`updated_at\` datetime not null, primary key (\`auth_id\`)) default character set utf8mb4 engine = InnoDB;`); + this.addSql(`alter table \`core_user\` add unique \`core_user_hack_tag_unique\` (\`hack_tag\`);`); + this.addSql(`alter table \`core_user\` add index \`core_user_hack_tag_index\` (\`hack_tag\`);`); + this.addSql(`alter table \`core_user\` add index \`core_user_role_id_index\` (\`role_id\`);`); + this.addSql(`alter table \`core_user\` add index \`core_user_created_at_index\` (\`created_at\`);`); + + this.addSql(`create table \`discord_verification\` (\`code\` varchar(255) not null, \`discord_user_id\` varchar(255) not null, \`guild_id\` varchar(255) not null, \`username\` varchar(255) not null, \`avatar_hash\` varchar(255) null, \`auth_id\` varchar(255) null, \`status\` enum('pending','accepted','rejected','expired') not null default 'pending', \`expires_at\` datetime not null, \`created_at\` datetime not null, \`accepted_at\` datetime null, primary key (\`code\`)) default character set utf8mb4 engine = InnoDB;`); + this.addSql(`alter table \`discord_verification\` add index \`discord_verification_discord_user_id_index\` (\`discord_user_id\`);`); + this.addSql(`alter table \`discord_verification\` add index \`discord_verification_guild_id_index\` (\`guild_id\`);`); + this.addSql(`alter table \`discord_verification\` add index \`discord_verification_auth_id_index\` (\`auth_id\`);`); + this.addSql(`alter table \`discord_verification\` add index \`discord_verification_status_index\` (\`status\`);`); + this.addSql(`alter table \`discord_verification\` add index \`discord_verification_created_at_index\` (\`created_at\`);`); + + this.addSql(`create table \`discord_role_sync_attempt\` (\`id\` varchar(255) not null, \`auth_id\` varchar(255) not null, \`discord_user_id\` varchar(255) not null, \`guild_id\` varchar(255) not null, \`status\` enum('synced','failed','skipped') not null, \`role_ids\` json not null default ('[]'), \`role_names\` json not null default ('[]'), \`error\` text null, \`created_at\` datetime not null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`); + this.addSql(`alter table \`discord_role_sync_attempt\` add index \`discord_role_sync_attempt_auth_id_index\` (\`auth_id\`);`); + this.addSql(`alter table \`discord_role_sync_attempt\` add index \`discord_role_sync_attempt_discord_user_id_index\` (\`discord_user_id\`);`); + this.addSql(`alter table \`discord_role_sync_attempt\` add index \`discord_role_sync_attempt_status_index\` (\`status\`);`); + this.addSql(`alter table \`discord_role_sync_attempt\` add index \`discord_role_sync_attempt_created_at_index\` (\`created_at\`);`); + + this.addSql(`create table \`discord_member\` (\`auth_id\` varchar(255) not null, \`discord_user_id\` varchar(255) not null, \`guild_id\` varchar(255) not null, \`username\` varchar(255) not null, \`avatar_hash\` varchar(255) null, \`verified_at\` datetime not null, \`updated_at\` datetime not null, \`last_role_sync_at\` datetime null, primary key (\`auth_id\`)) default character set utf8mb4 engine = InnoDB;`); + this.addSql(`alter table \`discord_member\` add index \`discord_member_auth_id_index\` (\`auth_id\`);`); + this.addSql(`alter table \`discord_member\` add unique \`discord_member_discord_user_id_unique\` (\`discord_user_id\`);`); + this.addSql(`alter table \`discord_member\` add index \`discord_member_discord_user_id_index\` (\`discord_user_id\`);`); + this.addSql(`alter table \`discord_member\` add index \`discord_member_guild_id_index\` (\`guild_id\`);`); + this.addSql(`alter table \`discord_member\` add index \`discord_member_updated_at_index\` (\`updated_at\`);`); + + this.addSql(`create table \`core_user_data\` (\`auth_id\` varchar(255) not null, \`age\` int not null, \`gender\` varchar(255) not null, \`race\` varchar(255) not null, \`ethnicity\` varchar(255) not null, \`shirt_size\` varchar(255) not null, \`dietary_restrictions\` json not null default ('[]'), \`accommodation_note\` text null, \`phone_number\` varchar(255) null, \`country_of_residence\` varchar(255) null, \`has_accepted_mlhcode_of_conduct\` tinyint(1) not null, \`has_shared_data_with_mlh\` tinyint(1) not null, \`is_emailable\` tinyint(1) not null, \`completed_at\` datetime not null, \`updated_at\` datetime not null, primary key (\`auth_id\`)) default character set utf8mb4 engine = InnoDB;`); + this.addSql(`alter table \`core_user_data\` add index \`core_user_data_auth_id_index\` (\`auth_id\`);`); + + this.addSql(`create table \`core_user_ban\` (\`auth_id\` varchar(255) not null, \`reason\` varchar(255) null, \`banned_by_auth_id\` varchar(255) not null, \`created_at\` datetime not null, primary key (\`auth_id\`)) default character set utf8mb4 engine = InnoDB;`); + this.addSql(`alter table \`core_user_ban\` add index \`core_user_ban_auth_id_index\` (\`auth_id\`);`); + this.addSql(`alter table \`core_user_ban\` add index \`core_user_ban_banned_by_auth_id_index\` (\`banned_by_auth_id\`);`); + + this.addSql(`create table \`core_setting\` (\`key\` varchar(255) not null, \`value\` json not null, \`created_at\` datetime not null, \`created_by_auth_id\` varchar(255) null, \`updated_at\` datetime not null, \`updated_by_auth_id\` varchar(255) null, primary key (\`key\`)) default character set utf8mb4 engine = InnoDB;`); + this.addSql(`alter table \`core_setting\` add index \`core_setting_created_by_auth_id_index\` (\`created_by_auth_id\`);`); + this.addSql(`alter table \`core_setting\` add index \`core_setting_updated_at_index\` (\`updated_at\`);`); + this.addSql(`alter table \`core_setting\` add index \`core_setting_updated_by_auth_id_index\` (\`updated_by_auth_id\`);`); + + this.addSql(`create table \`core_rsvp\` (\`auth_id\` varchar(255) not null, \`status\` enum('confirmed','waitlisted','cancelled') not null, \`waitlist_position\` int null, \`created_at\` datetime not null, \`updated_at\` datetime not null, \`confirmed_at\` datetime null, \`waitlisted_at\` datetime null, \`cancelled_at\` datetime null, \`cancelled_by_auth_id\` varchar(255) null, \`promoted_at\` datetime null, \`promoted_by_auth_id\` varchar(255) null, primary key (\`auth_id\`)) default character set utf8mb4 engine = InnoDB;`); + this.addSql(`alter table \`core_rsvp\` add index \`core_rsvp_auth_id_index\` (\`auth_id\`);`); + this.addSql(`alter table \`core_rsvp\` add index \`core_rsvp_cancelled_by_auth_id_index\` (\`cancelled_by_auth_id\`);`); + this.addSql(`alter table \`core_rsvp\` add index \`core_rsvp_promoted_by_auth_id_index\` (\`promoted_by_auth_id\`);`); + this.addSql(`alter table \`core_rsvp\` add index \`core_rsvp_status_index\` (\`status\`);`); + this.addSql(`alter table \`core_rsvp\` add index \`core_rsvp_waitlist_position_index\` (\`waitlist_position\`);`); + this.addSql(`alter table \`core_rsvp\` add index \`core_rsvp_created_at_index\` (\`created_at\`);`); + this.addSql(`alter table \`core_rsvp\` add index \`core_rsvp_updated_at_index\` (\`updated_at\`);`); + + this.addSql(`create table \`core_notification_intent\` (\`id\` varchar(255) not null, \`kind\` varchar(255) not null, \`recipient_auth_id\` varchar(255) null, \`payload\` json not null, \`status\` enum('pending','processing','delivered','failed','skipped') not null default 'pending', \`idempotency_key\` varchar(255) null, \`created_at\` datetime not null, \`updated_at\` datetime not null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`); + this.addSql(`alter table \`core_notification_intent\` add unique \`core_notification_intent_idempotency_key_unique\` (\`idempotency_key\`);`); + this.addSql(`alter table \`core_notification_intent\` add index \`core_notification_intent_kind_index\` (\`kind\`);`); + this.addSql(`alter table \`core_notification_intent\` add index \`core_notification_intent_recipient_auth_id_index\` (\`recipient_auth_id\`);`); + this.addSql(`alter table \`core_notification_intent\` add index \`core_notification_intent_status_index\` (\`status\`);`); + this.addSql(`alter table \`core_notification_intent\` add index \`core_notification_intent_created_at_index\` (\`created_at\`);`); + + this.addSql(`create table \`core_notification_delivery_attempt\` (\`id\` varchar(255) not null, \`intent_id\` varchar(255) not null, \`channel\` varchar(255) not null, \`provider\` varchar(255) null, \`status\` enum('delivered','failed','skipped') not null, \`recipient\` varchar(255) null, \`external_id\` varchar(255) null, \`error\` text null, \`metadata\` json not null, \`attempted_at\` datetime not null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`); + this.addSql(`alter table \`core_notification_delivery_attempt\` add index \`core_notification_delivery_attempt_intent_id_index\` (\`intent_id\`);`); + this.addSql(`alter table \`core_notification_delivery_attempt\` add index \`core_notification_delivery_attempt_channel_index\` (\`channel\`);`); + this.addSql(`alter table \`core_notification_delivery_attempt\` add index \`core_notification_delivery_attempt_status_index\` (\`status\`);`); + this.addSql(`alter table \`core_notification_delivery_attempt\` add index \`core_notification_delivery_attempt_attempted_at_index\` (\`attempted_at\`);`); + + this.addSql(`create table \`core_hacker\` (\`auth_id\` varchar(255) not null, \`university\` varchar(255) not null, \`major\` varchar(255) not null, \`school_id\` varchar(255) null, \`level_of_study\` varchar(255) not null, \`hackathons_attended\` int not null, \`software_experience\` varchar(255) not null, \`heard_from\` varchar(255) null, \`github_url\` text null, \`linked_in_url\` text null, \`personal_website_url\` text null, \`resume_url\` text null, \`group\` varchar(255) null, \`registered_at\` datetime not null, \`updated_at\` datetime not null, primary key (\`auth_id\`)) default character set utf8mb4 engine = InnoDB;`); + this.addSql(`alter table \`core_hacker\` add index \`core_hacker_auth_id_index\` (\`auth_id\`);`); + this.addSql(`alter table \`core_hacker\` add index \`core_hacker_registered_at_index\` (\`registered_at\`);`); + + this.addSql(`create table \`core_event_scan\` (\`id\` varchar(255) not null, \`event_id\` varchar(255) not null, \`auth_id\` varchar(255) not null, \`scanned_by_auth_id\` varchar(255) not null, \`scanned_at\` datetime not null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`); + this.addSql(`alter table \`core_event_scan\` add index \`core_event_scan_scanned_by_auth_id_index\` (\`scanned_by_auth_id\`);`); + this.addSql(`alter table \`core_event_scan\` add index \`core_event_scan_event_id_index\` (\`event_id\`);`); + this.addSql(`alter table \`core_event_scan\` add index \`core_event_scan_auth_id_index\` (\`auth_id\`);`); + this.addSql(`alter table \`core_event_scan\` add index \`core_event_scan_event_id_auth_id_index\` (\`event_id\`, \`auth_id\`);`); + this.addSql(`alter table \`core_event_scan\` add index \`core_event_scan_scanned_at_index\` (\`scanned_at\`);`); + + this.addSql(`create table \`teams_team\` (\`id\` varchar(255) not null, \`name\` varchar(255) not null, \`tag\` varchar(255) not null, \`owner_auth_id\` varchar(255) not null, \`created_at\` datetime not null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`); + this.addSql(`alter table \`teams_team\` add unique \`teams_team_tag_unique\` (\`tag\`);`); + this.addSql(`alter table \`teams_team\` add index \`teams_team_tag_index\` (\`tag\`);`); + this.addSql(`alter table \`teams_team\` add index \`teams_team_owner_auth_id_index\` (\`owner_auth_id\`);`); + + this.addSql(`create table \`teams_member\` (\`id\` varchar(255) not null, \`team_id\` varchar(255) not null, \`auth_id\` varchar(255) not null, \`joined_at\` datetime not null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`); + this.addSql(`alter table \`teams_member\` add unique \`teams_member_auth_id_unique\` (\`auth_id\`);`); + this.addSql(`alter table \`teams_member\` add index \`teams_member_team_id_index\` (\`team_id\`);`); + this.addSql(`alter table \`teams_member\` add index \`teams_member_auth_id_index\` (\`auth_id\`);`); + + this.addSql(`create table \`teams_invite\` (\`id\` varchar(255) not null, \`team_id\` varchar(255) not null, \`invitee_auth_id\` varchar(255) not null, \`status\` enum('pending','accepted','declined') not null default 'pending', \`created_at\` datetime not null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`); + this.addSql(`alter table \`teams_invite\` add index \`teams_invite_team_id_index\` (\`team_id\`);`); + this.addSql(`alter table \`teams_invite\` add index \`teams_invite_invitee_auth_id_index\` (\`invitee_auth_id\`);`); + this.addSql(`alter table \`teams_invite\` add index \`teams_invite_team_id_invitee_auth_id_index\` (\`team_id\`, \`invitee_auth_id\`);`); + + this.addSql(`alter table \`session\` add constraint \`session_user_id_foreign\` foreign key (\`user_id\`) references \`user\` (\`id\`) on delete cascade;`); + + this.addSql(`alter table \`account\` add constraint \`account_user_id_foreign\` foreign key (\`user_id\`) references \`user\` (\`id\`) on delete cascade;`); + + this.addSql(`alter table \`core_user\` add constraint \`core_user_auth_id_foreign\` foreign key (\`auth_id\`) references \`user\` (\`id\`) on update cascade on delete cascade;`); + this.addSql(`alter table \`core_user\` add constraint \`core_user_role_id_foreign\` foreign key (\`role_id\`) references \`core_role\` (\`id\`) on delete set null;`); + + this.addSql(`alter table \`discord_verification\` add constraint \`discord_verification_auth_id_foreign\` foreign key (\`auth_id\`) references \`core_user\` (\`auth_id\`) on delete set null;`); + + this.addSql(`alter table \`discord_role_sync_attempt\` add constraint \`discord_role_sync_attempt_auth_id_foreign\` foreign key (\`auth_id\`) references \`core_user\` (\`auth_id\`) on delete cascade;`); + + this.addSql(`alter table \`discord_member\` add constraint \`discord_member_auth_id_foreign\` foreign key (\`auth_id\`) references \`core_user\` (\`auth_id\`) on update cascade on delete cascade;`); + + this.addSql(`alter table \`core_user_data\` add constraint \`core_user_data_auth_id_foreign\` foreign key (\`auth_id\`) references \`core_user\` (\`auth_id\`) on update cascade on delete cascade;`); + + this.addSql(`alter table \`core_user_ban\` add constraint \`core_user_ban_auth_id_foreign\` foreign key (\`auth_id\`) references \`core_user\` (\`auth_id\`) on update cascade on delete cascade;`); + this.addSql(`alter table \`core_user_ban\` add constraint \`core_user_ban_banned_by_auth_id_foreign\` foreign key (\`banned_by_auth_id\`) references \`core_user\` (\`auth_id\`);`); + + this.addSql(`alter table \`core_setting\` add constraint \`core_setting_created_by_auth_id_foreign\` foreign key (\`created_by_auth_id\`) references \`core_user\` (\`auth_id\`) on delete set null;`); + this.addSql(`alter table \`core_setting\` add constraint \`core_setting_updated_by_auth_id_foreign\` foreign key (\`updated_by_auth_id\`) references \`core_user\` (\`auth_id\`) on delete set null;`); + + this.addSql(`alter table \`core_rsvp\` add constraint \`core_rsvp_auth_id_foreign\` foreign key (\`auth_id\`) references \`core_user\` (\`auth_id\`) on update cascade on delete cascade;`); + this.addSql(`alter table \`core_rsvp\` add constraint \`core_rsvp_cancelled_by_auth_id_foreign\` foreign key (\`cancelled_by_auth_id\`) references \`core_user\` (\`auth_id\`) on delete set null;`); + this.addSql(`alter table \`core_rsvp\` add constraint \`core_rsvp_promoted_by_auth_id_foreign\` foreign key (\`promoted_by_auth_id\`) references \`core_user\` (\`auth_id\`) on delete set null;`); + + this.addSql(`alter table \`core_notification_intent\` add constraint \`core_notification_intent_recipient_auth_id_foreign\` foreign key (\`recipient_auth_id\`) references \`core_user\` (\`auth_id\`) on delete set null;`); + + this.addSql(`alter table \`core_notification_delivery_attempt\` add constraint \`core_notification_delivery_attempt_intent_id_foreign\` foreign key (\`intent_id\`) references \`core_notification_intent\` (\`id\`) on delete cascade;`); + + this.addSql(`alter table \`core_hacker\` add constraint \`core_hacker_auth_id_foreign\` foreign key (\`auth_id\`) references \`core_user\` (\`auth_id\`) on update cascade on delete cascade;`); + + this.addSql(`alter table \`core_event_scan\` add constraint \`core_event_scan_event_id_foreign\` foreign key (\`event_id\`) references \`core_event\` (\`id\`) on delete cascade;`); + this.addSql(`alter table \`core_event_scan\` add constraint \`core_event_scan_auth_id_foreign\` foreign key (\`auth_id\`) references \`core_user\` (\`auth_id\`) on delete cascade;`); + this.addSql(`alter table \`core_event_scan\` add constraint \`core_event_scan_scanned_by_auth_id_foreign\` foreign key (\`scanned_by_auth_id\`) references \`core_user\` (\`auth_id\`);`); + + this.addSql(`alter table \`teams_team\` add constraint \`teams_team_owner_auth_id_foreign\` foreign key (\`owner_auth_id\`) references \`core_user\` (\`auth_id\`) on delete cascade;`); + + this.addSql(`alter table \`teams_member\` add constraint \`teams_member_team_id_foreign\` foreign key (\`team_id\`) references \`teams_team\` (\`id\`) on delete cascade;`); + this.addSql(`alter table \`teams_member\` add constraint \`teams_member_auth_id_foreign\` foreign key (\`auth_id\`) references \`core_user\` (\`auth_id\`) on delete cascade;`); + + this.addSql(`alter table \`teams_invite\` add constraint \`teams_invite_team_id_foreign\` foreign key (\`team_id\`) references \`teams_team\` (\`id\`) on delete cascade;`); + this.addSql(`alter table \`teams_invite\` add constraint \`teams_invite_invitee_auth_id_foreign\` foreign key (\`invitee_auth_id\`) references \`core_user\` (\`auth_id\`) on delete cascade;`); + } + + override down(): void | Promise { + this.addSql(`alter table \`session\` drop foreign key \`session_user_id_foreign\`;`); + this.addSql(`alter table \`account\` drop foreign key \`account_user_id_foreign\`;`); + this.addSql(`alter table \`core_user\` drop foreign key \`core_user_auth_id_foreign\`;`); + this.addSql(`alter table \`core_event_scan\` drop foreign key \`core_event_scan_event_id_foreign\`;`); + this.addSql(`alter table \`core_user\` drop foreign key \`core_user_role_id_foreign\`;`); + this.addSql(`alter table \`discord_verification\` drop foreign key \`discord_verification_auth_id_foreign\`;`); + this.addSql(`alter table \`discord_role_sync_attempt\` drop foreign key \`discord_role_sync_attempt_auth_id_foreign\`;`); + this.addSql(`alter table \`discord_member\` drop foreign key \`discord_member_auth_id_foreign\`;`); + this.addSql(`alter table \`core_user_data\` drop foreign key \`core_user_data_auth_id_foreign\`;`); + this.addSql(`alter table \`core_user_ban\` drop foreign key \`core_user_ban_auth_id_foreign\`;`); + this.addSql(`alter table \`core_user_ban\` drop foreign key \`core_user_ban_banned_by_auth_id_foreign\`;`); + this.addSql(`alter table \`core_setting\` drop foreign key \`core_setting_created_by_auth_id_foreign\`;`); + this.addSql(`alter table \`core_setting\` drop foreign key \`core_setting_updated_by_auth_id_foreign\`;`); + this.addSql(`alter table \`core_rsvp\` drop foreign key \`core_rsvp_auth_id_foreign\`;`); + this.addSql(`alter table \`core_rsvp\` drop foreign key \`core_rsvp_cancelled_by_auth_id_foreign\`;`); + this.addSql(`alter table \`core_rsvp\` drop foreign key \`core_rsvp_promoted_by_auth_id_foreign\`;`); + this.addSql(`alter table \`core_notification_intent\` drop foreign key \`core_notification_intent_recipient_auth_id_foreign\`;`); + this.addSql(`alter table \`core_hacker\` drop foreign key \`core_hacker_auth_id_foreign\`;`); + this.addSql(`alter table \`core_event_scan\` drop foreign key \`core_event_scan_auth_id_foreign\`;`); + this.addSql(`alter table \`core_event_scan\` drop foreign key \`core_event_scan_scanned_by_auth_id_foreign\`;`); + this.addSql(`alter table \`teams_team\` drop foreign key \`teams_team_owner_auth_id_foreign\`;`); + this.addSql(`alter table \`teams_member\` drop foreign key \`teams_member_auth_id_foreign\`;`); + this.addSql(`alter table \`teams_invite\` drop foreign key \`teams_invite_invitee_auth_id_foreign\`;`); + this.addSql(`alter table \`core_notification_delivery_attempt\` drop foreign key \`core_notification_delivery_attempt_intent_id_foreign\`;`); + this.addSql(`alter table \`teams_member\` drop foreign key \`teams_member_team_id_foreign\`;`); + this.addSql(`alter table \`teams_invite\` drop foreign key \`teams_invite_team_id_foreign\`;`); + + this.addSql(`drop table if exists \`user\`;`); + this.addSql(`drop table if exists \`session\`;`); + this.addSql(`drop table if exists \`account\`;`); + this.addSql(`drop table if exists \`verification\`;`); + this.addSql(`drop table if exists \`core_event\`;`); + this.addSql(`drop table if exists \`core_operation_lock\`;`); + this.addSql(`drop table if exists \`core_role\`;`); + this.addSql(`drop table if exists \`core_user\`;`); + this.addSql(`drop table if exists \`discord_verification\`;`); + this.addSql(`drop table if exists \`discord_role_sync_attempt\`;`); + this.addSql(`drop table if exists \`discord_member\`;`); + this.addSql(`drop table if exists \`core_user_data\`;`); + this.addSql(`drop table if exists \`core_user_ban\`;`); + this.addSql(`drop table if exists \`core_setting\`;`); + this.addSql(`drop table if exists \`core_rsvp\`;`); + this.addSql(`drop table if exists \`core_notification_intent\`;`); + this.addSql(`drop table if exists \`core_notification_delivery_attempt\`;`); + this.addSql(`drop table if exists \`core_hacker\`;`); + this.addSql(`drop table if exists \`core_event_scan\`;`); + this.addSql(`drop table if exists \`teams_team\`;`); + this.addSql(`drop table if exists \`teams_member\`;`); + this.addSql(`drop table if exists \`teams_invite\`;`); + } + +} diff --git a/apps/web/db/migrations/postgresql/.snapshot.json b/apps/web/db/migrations/postgresql/.snapshot.json new file mode 100644 index 00000000..f08f0114 --- /dev/null +++ b/apps/web/db/migrations/postgresql/.snapshot.json @@ -0,0 +1,4586 @@ +{ + "name": "public", + "namespaces": [ + "public" + ], + "tables": [ + { + "name": "account", + "schema": "public", + "columns": { + "access_token": { + "name": "access_token", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "account_id": { + "name": "account_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "id": { + "name": "id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "id_token": { + "name": "id_token", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "password": { + "name": "password", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "scope": { + "name": "scope", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + } + }, + "indexes": [ + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "account_pkey", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "user_id" + ], + "composite": false, + "constraint": false, + "keyName": "account_user_id_index", + "primary": false, + "unique": false + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "account_user_id_foreign": { + "columnNames": [ + "user_id" + ], + "constraintName": "account_user_id_foreign", + "localTableName": "public.account", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.user", + "deleteRule": "cascade" + } + }, + "comment": null + }, + { + "name": "core_event", + "schema": "public", + "columns": { + "created_at": { + "name": "created_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "description": { + "name": "description", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "end_time": { + "name": "end_time", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "hidden": { + "name": "hidden", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "false", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "boolean" + }, + "host": { + "name": "host", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "id": { + "name": "id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "location": { + "name": "location", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": "'TBD'", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "start_time": { + "name": "start_time", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "title": { + "name": "title", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "type": { + "name": "type", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "indexes": [ + { + "columnNames": [ + "hidden" + ], + "composite": false, + "constraint": false, + "keyName": "core_event_hidden_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "core_event_pkey", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "start_time" + ], + "composite": false, + "constraint": false, + "keyName": "core_event_start_time_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "type" + ], + "composite": false, + "constraint": false, + "keyName": "core_event_type_index", + "primary": false, + "unique": false + } + ], + "checks": [], + "triggers": [], + "foreignKeys": {}, + "comment": null + }, + { + "name": "core_event_scan", + "schema": "public", + "columns": { + "auth_id": { + "name": "auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "event_id": { + "name": "event_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "id": { + "name": "id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "scanned_at": { + "name": "scanned_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "scanned_by_auth_id": { + "name": "scanned_by_auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + } + }, + "indexes": [ + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_event_scan_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "event_id", + "auth_id" + ], + "composite": true, + "constraint": false, + "keyName": "core_event_scan_event_id_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "event_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_event_scan_event_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "core_event_scan_pkey", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "scanned_at" + ], + "composite": false, + "constraint": false, + "keyName": "core_event_scan_scanned_at_index", + "primary": false, + "unique": false + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "core_event_scan_auth_id_foreign": { + "columnNames": [ + "auth_id" + ], + "constraintName": "core_event_scan_auth_id_foreign", + "localTableName": "public.core_event_scan", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "public.core_user", + "deleteRule": "cascade" + }, + "core_event_scan_event_id_foreign": { + "columnNames": [ + "event_id" + ], + "constraintName": "core_event_scan_event_id_foreign", + "localTableName": "public.core_event_scan", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.core_event", + "deleteRule": "cascade" + }, + "core_event_scan_scanned_by_auth_id_foreign": { + "columnNames": [ + "scanned_by_auth_id" + ], + "constraintName": "core_event_scan_scanned_by_auth_id_foreign", + "localTableName": "public.core_event_scan", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "public.core_user" + } + }, + "comment": null + }, + { + "name": "core_hacker", + "schema": "public", + "columns": { + "auth_id": { + "name": "auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "github_url": { + "name": "github_url", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "group": { + "name": "group", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "hackathons_attended": { + "name": "hackathons_attended", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "integer" + }, + "heard_from": { + "name": "heard_from", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "level_of_study": { + "name": "level_of_study", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "linked_in_url": { + "name": "linked_in_url", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "major": { + "name": "major", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "personal_website_url": { + "name": "personal_website_url", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "registered_at": { + "name": "registered_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "resume_url": { + "name": "resume_url", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "school_id": { + "name": "school_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "software_experience": { + "name": "software_experience", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "university": { + "name": "university", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "indexes": [ + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": true, + "keyName": "core_hacker_pkey", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "registered_at" + ], + "composite": false, + "constraint": false, + "keyName": "core_hacker_registered_at_index", + "primary": false, + "unique": false + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "core_hacker_auth_id_foreign": { + "columnNames": [ + "auth_id" + ], + "constraintName": "core_hacker_auth_id_foreign", + "localTableName": "public.core_hacker", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "public.core_user", + "updateRule": "cascade", + "deleteRule": "cascade" + } + }, + "comment": null + }, + { + "name": "core_notification_delivery_attempt", + "schema": "public", + "columns": { + "attempted_at": { + "name": "attempted_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "channel": { + "name": "channel", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "error": { + "name": "error", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "id": { + "name": "id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "intent_id": { + "name": "intent_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "json" + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "recipient": { + "name": "recipient", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "status": { + "name": "status", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [ + "delivered", + "failed", + "skipped" + ], + "mappedType": "enum" + } + }, + "indexes": [ + { + "columnNames": [ + "attempted_at" + ], + "composite": false, + "constraint": false, + "keyName": "core_notification_delivery_attempt_attempted_at_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "channel" + ], + "composite": false, + "constraint": false, + "keyName": "core_notification_delivery_attempt_channel_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "intent_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_notification_delivery_attempt_intent_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "core_notification_delivery_attempt_pkey", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "status" + ], + "composite": false, + "constraint": false, + "keyName": "core_notification_delivery_attempt_status_index", + "primary": false, + "unique": false + } + ], + "checks": [ + { + "name": "core_notification_delivery_attempt_status_check", + "expression": "\"status\" in ('delivered', 'failed', 'skipped')", + "definition": "check (\"status\" in ('delivered', 'failed', 'skipped'))", + "columnName": "status" + } + ], + "triggers": [], + "foreignKeys": { + "core_notification_delivery_attempt_intent_id_foreign": { + "columnNames": [ + "intent_id" + ], + "constraintName": "core_notification_delivery_attempt_intent_id_foreign", + "localTableName": "public.core_notification_delivery_attempt", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.core_notification_intent", + "deleteRule": "cascade" + } + }, + "comment": null + }, + { + "name": "core_notification_intent", + "schema": "public", + "columns": { + "created_at": { + "name": "created_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "id": { + "name": "id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": true, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "kind": { + "name": "kind", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "json" + }, + "recipient_auth_id": { + "name": "recipient_auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "status": { + "name": "status", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "'pending'", + "comment": null, + "collation": null, + "enumItems": [ + "pending", + "processing", + "delivered", + "failed", + "skipped" + ], + "mappedType": "enum" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "indexes": [ + { + "columnNames": [ + "created_at" + ], + "composite": false, + "constraint": false, + "keyName": "core_notification_intent_created_at_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "idempotency_key" + ], + "composite": false, + "constraint": true, + "keyName": "core_notification_intent_idempotency_key_unique", + "primary": false, + "unique": true + }, + { + "columnNames": [ + "kind" + ], + "composite": false, + "constraint": false, + "keyName": "core_notification_intent_kind_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "core_notification_intent_pkey", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "recipient_auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_notification_intent_recipient_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "status" + ], + "composite": false, + "constraint": false, + "keyName": "core_notification_intent_status_index", + "primary": false, + "unique": false + } + ], + "checks": [ + { + "name": "core_notification_intent_status_check", + "expression": "\"status\" in ('pending', 'processing', 'delivered', 'failed', 'skipped')", + "definition": "check (\"status\" in ('pending', 'processing', 'delivered', 'failed', 'skipped'))", + "columnName": "status" + } + ], + "triggers": [], + "foreignKeys": { + "core_notification_intent_recipient_auth_id_foreign": { + "columnNames": [ + "recipient_auth_id" + ], + "constraintName": "core_notification_intent_recipient_auth_id_foreign", + "localTableName": "public.core_notification_intent", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "public.core_user", + "deleteRule": "set null" + } + }, + "comment": null + }, + { + "name": "core_operation_lock", + "schema": "public", + "columns": { + "key": { + "name": "key", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "token": { + "name": "token", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + } + }, + "indexes": [ + { + "columnNames": [ + "key" + ], + "composite": false, + "constraint": true, + "keyName": "core_operation_lock_pkey", + "primary": true, + "unique": true + } + ], + "checks": [], + "triggers": [], + "foreignKeys": {}, + "comment": null + }, + { + "name": "core_role", + "schema": "public", + "columns": { + "color": { + "name": "color", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "id": { + "name": "id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": true, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "'[]'", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "json" + }, + "position": { + "name": "position", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "integer" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "indexes": [ + { + "columnNames": [ + "name" + ], + "composite": false, + "constraint": true, + "keyName": "core_role_name_unique", + "primary": false, + "unique": true + }, + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "core_role_pkey", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "position" + ], + "composite": false, + "constraint": false, + "keyName": "core_role_position_index", + "primary": false, + "unique": false + } + ], + "checks": [], + "triggers": [], + "foreignKeys": {}, + "comment": null + }, + { + "name": "core_rsvp", + "schema": "public", + "columns": { + "auth_id": { + "name": "auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "cancelled_by_auth_id": { + "name": "cancelled_by_auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "promoted_at": { + "name": "promoted_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "promoted_by_auth_id": { + "name": "promoted_by_auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "status": { + "name": "status", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [ + "confirmed", + "waitlisted", + "cancelled" + ], + "mappedType": "enum" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "waitlist_position": { + "name": "waitlist_position", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "integer" + }, + "waitlisted_at": { + "name": "waitlisted_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "indexes": [ + { + "columnNames": [ + "created_at" + ], + "composite": false, + "constraint": false, + "keyName": "core_rsvp_created_at_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": true, + "keyName": "core_rsvp_pkey", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "status" + ], + "composite": false, + "constraint": false, + "keyName": "core_rsvp_status_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "updated_at" + ], + "composite": false, + "constraint": false, + "keyName": "core_rsvp_updated_at_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "waitlist_position" + ], + "composite": false, + "constraint": false, + "keyName": "core_rsvp_waitlist_position_index", + "primary": false, + "unique": false + } + ], + "checks": [ + { + "name": "core_rsvp_status_check", + "expression": "\"status\" in ('confirmed', 'waitlisted', 'cancelled')", + "definition": "check (\"status\" in ('confirmed', 'waitlisted', 'cancelled'))", + "columnName": "status" + } + ], + "triggers": [], + "foreignKeys": { + "core_rsvp_auth_id_foreign": { + "columnNames": [ + "auth_id" + ], + "constraintName": "core_rsvp_auth_id_foreign", + "localTableName": "public.core_rsvp", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "public.core_user", + "updateRule": "cascade", + "deleteRule": "cascade" + }, + "core_rsvp_cancelled_by_auth_id_foreign": { + "columnNames": [ + "cancelled_by_auth_id" + ], + "constraintName": "core_rsvp_cancelled_by_auth_id_foreign", + "localTableName": "public.core_rsvp", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "public.core_user", + "deleteRule": "set null" + }, + "core_rsvp_promoted_by_auth_id_foreign": { + "columnNames": [ + "promoted_by_auth_id" + ], + "constraintName": "core_rsvp_promoted_by_auth_id_foreign", + "localTableName": "public.core_rsvp", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "public.core_user", + "deleteRule": "set null" + } + }, + "comment": null + }, + { + "name": "core_setting", + "schema": "public", + "columns": { + "created_at": { + "name": "created_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "created_by_auth_id": { + "name": "created_by_auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "key": { + "name": "key", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "updated_by_auth_id": { + "name": "updated_by_auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "value": { + "name": "value", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "json" + } + }, + "indexes": [ + { + "columnNames": [ + "key" + ], + "composite": false, + "constraint": true, + "keyName": "core_setting_pkey", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "updated_at" + ], + "composite": false, + "constraint": false, + "keyName": "core_setting_updated_at_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "updated_by_auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_setting_updated_by_auth_id_index", + "primary": false, + "unique": false + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "core_setting_created_by_auth_id_foreign": { + "columnNames": [ + "created_by_auth_id" + ], + "constraintName": "core_setting_created_by_auth_id_foreign", + "localTableName": "public.core_setting", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "public.core_user", + "deleteRule": "set null" + }, + "core_setting_updated_by_auth_id_foreign": { + "columnNames": [ + "updated_by_auth_id" + ], + "constraintName": "core_setting_updated_by_auth_id_foreign", + "localTableName": "public.core_setting", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "public.core_user", + "deleteRule": "set null" + } + }, + "comment": null + }, + { + "name": "core_user", + "schema": "public", + "columns": { + "auth_id": { + "name": "auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "bio": { + "name": "bio", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "checked_in_at": { + "name": "checked_in_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "discord_display_handle": { + "name": "discord_display_handle", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "first_name": { + "name": "first_name", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "hack_tag": { + "name": "hack_tag", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": true, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "is_approved": { + "name": "is_approved", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "false", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "boolean" + }, + "is_profile_searchable": { + "name": "is_profile_searchable", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "true", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "boolean" + }, + "last_name": { + "name": "last_name", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "profile_photo_url": { + "name": "profile_photo_url", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "pronouns": { + "name": "pronouns", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "role_id": { + "name": "role_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "skills": { + "name": "skills", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "'[]'", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "json" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "indexes": [ + { + "columnNames": [ + "created_at" + ], + "composite": false, + "constraint": false, + "keyName": "core_user_created_at_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "hack_tag" + ], + "composite": false, + "constraint": false, + "keyName": "core_user_hack_tag_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "hack_tag" + ], + "composite": false, + "constraint": true, + "keyName": "core_user_hack_tag_unique", + "primary": false, + "unique": true + }, + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": true, + "keyName": "core_user_pkey", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "role_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_user_role_id_index", + "primary": false, + "unique": false + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "core_user_auth_id_foreign": { + "columnNames": [ + "auth_id" + ], + "constraintName": "core_user_auth_id_foreign", + "localTableName": "public.core_user", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.user", + "updateRule": "cascade", + "deleteRule": "cascade" + }, + "core_user_role_id_foreign": { + "columnNames": [ + "role_id" + ], + "constraintName": "core_user_role_id_foreign", + "localTableName": "public.core_user", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.core_role", + "deleteRule": "set null" + } + }, + "comment": null + }, + { + "name": "core_user_ban", + "schema": "public", + "columns": { + "auth_id": { + "name": "auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "banned_by_auth_id": { + "name": "banned_by_auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "reason": { + "name": "reason", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + } + }, + "indexes": [ + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": true, + "keyName": "core_user_ban_pkey", + "primary": true, + "unique": true + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "core_user_ban_auth_id_foreign": { + "columnNames": [ + "auth_id" + ], + "constraintName": "core_user_ban_auth_id_foreign", + "localTableName": "public.core_user_ban", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "public.core_user", + "updateRule": "cascade", + "deleteRule": "cascade" + }, + "core_user_ban_banned_by_auth_id_foreign": { + "columnNames": [ + "banned_by_auth_id" + ], + "constraintName": "core_user_ban_banned_by_auth_id_foreign", + "localTableName": "public.core_user_ban", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "public.core_user" + } + }, + "comment": null + }, + { + "name": "core_user_data", + "schema": "public", + "columns": { + "accommodation_note": { + "name": "accommodation_note", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "age": { + "name": "age", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "integer" + }, + "auth_id": { + "name": "auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "country_of_residence": { + "name": "country_of_residence", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "dietary_restrictions": { + "name": "dietary_restrictions", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "'[]'", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "json" + }, + "ethnicity": { + "name": "ethnicity", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "gender": { + "name": "gender", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "has_accepted_mlhcode_of_conduct": { + "name": "has_accepted_mlhcode_of_conduct", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "boolean" + }, + "has_shared_data_with_mlh": { + "name": "has_shared_data_with_mlh", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "boolean" + }, + "is_emailable": { + "name": "is_emailable", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "boolean" + }, + "phone_number": { + "name": "phone_number", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "race": { + "name": "race", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "shirt_size": { + "name": "shirt_size", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "indexes": [ + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": true, + "keyName": "core_user_data_pkey", + "primary": true, + "unique": true + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "core_user_data_auth_id_foreign": { + "columnNames": [ + "auth_id" + ], + "constraintName": "core_user_data_auth_id_foreign", + "localTableName": "public.core_user_data", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "public.core_user", + "updateRule": "cascade", + "deleteRule": "cascade" + } + }, + "comment": null + }, + { + "name": "discord_member", + "schema": "public", + "columns": { + "auth_id": { + "name": "auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "avatar_hash": { + "name": "avatar_hash", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "discord_user_id": { + "name": "discord_user_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": true, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "guild_id": { + "name": "guild_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "last_role_sync_at": { + "name": "last_role_sync_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "username": { + "name": "username", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "verified_at": { + "name": "verified_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "indexes": [ + { + "columnNames": [ + "discord_user_id" + ], + "composite": false, + "constraint": false, + "keyName": "discord_member_discord_user_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "discord_user_id" + ], + "composite": false, + "constraint": true, + "keyName": "discord_member_discord_user_id_unique", + "primary": false, + "unique": true + }, + { + "columnNames": [ + "guild_id" + ], + "composite": false, + "constraint": false, + "keyName": "discord_member_guild_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": true, + "keyName": "discord_member_pkey", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "updated_at" + ], + "composite": false, + "constraint": false, + "keyName": "discord_member_updated_at_index", + "primary": false, + "unique": false + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "discord_member_auth_id_foreign": { + "columnNames": [ + "auth_id" + ], + "constraintName": "discord_member_auth_id_foreign", + "localTableName": "public.discord_member", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "public.core_user", + "updateRule": "cascade", + "deleteRule": "cascade" + } + }, + "comment": null + }, + { + "name": "discord_role_sync_attempt", + "schema": "public", + "columns": { + "auth_id": { + "name": "auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "discord_user_id": { + "name": "discord_user_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "error": { + "name": "error", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "guild_id": { + "name": "guild_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "id": { + "name": "id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "role_ids": { + "name": "role_ids", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "'[]'", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "json" + }, + "role_names": { + "name": "role_names", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "'[]'", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "json" + }, + "status": { + "name": "status", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [ + "synced", + "failed", + "skipped" + ], + "mappedType": "enum" + } + }, + "indexes": [ + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "discord_role_sync_attempt_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "created_at" + ], + "composite": false, + "constraint": false, + "keyName": "discord_role_sync_attempt_created_at_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "discord_user_id" + ], + "composite": false, + "constraint": false, + "keyName": "discord_role_sync_attempt_discord_user_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "discord_role_sync_attempt_pkey", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "status" + ], + "composite": false, + "constraint": false, + "keyName": "discord_role_sync_attempt_status_index", + "primary": false, + "unique": false + } + ], + "checks": [ + { + "name": "discord_role_sync_attempt_status_check", + "expression": "\"status\" in ('synced', 'failed', 'skipped')", + "definition": "check (\"status\" in ('synced', 'failed', 'skipped'))", + "columnName": "status" + } + ], + "triggers": [], + "foreignKeys": { + "discord_role_sync_attempt_auth_id_foreign": { + "columnNames": [ + "auth_id" + ], + "constraintName": "discord_role_sync_attempt_auth_id_foreign", + "localTableName": "public.discord_role_sync_attempt", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "public.core_user", + "deleteRule": "cascade" + } + }, + "comment": null + }, + { + "name": "discord_verification", + "schema": "public", + "columns": { + "accepted_at": { + "name": "accepted_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "auth_id": { + "name": "auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "avatar_hash": { + "name": "avatar_hash", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "code": { + "name": "code", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "discord_user_id": { + "name": "discord_user_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "guild_id": { + "name": "guild_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "status": { + "name": "status", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "'pending'", + "comment": null, + "collation": null, + "enumItems": [ + "pending", + "accepted", + "rejected", + "expired" + ], + "mappedType": "enum" + }, + "username": { + "name": "username", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + } + }, + "indexes": [ + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "discord_verification_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "created_at" + ], + "composite": false, + "constraint": false, + "keyName": "discord_verification_created_at_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "discord_user_id" + ], + "composite": false, + "constraint": false, + "keyName": "discord_verification_discord_user_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "guild_id" + ], + "composite": false, + "constraint": false, + "keyName": "discord_verification_guild_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "code" + ], + "composite": false, + "constraint": true, + "keyName": "discord_verification_pkey", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "status" + ], + "composite": false, + "constraint": false, + "keyName": "discord_verification_status_index", + "primary": false, + "unique": false + } + ], + "checks": [ + { + "name": "discord_verification_status_check", + "expression": "\"status\" in ('pending', 'accepted', 'rejected', 'expired')", + "definition": "check (\"status\" in ('pending', 'accepted', 'rejected', 'expired'))", + "columnName": "status" + } + ], + "triggers": [], + "foreignKeys": { + "discord_verification_auth_id_foreign": { + "columnNames": [ + "auth_id" + ], + "constraintName": "discord_verification_auth_id_foreign", + "localTableName": "public.discord_verification", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "public.core_user", + "deleteRule": "set null" + } + }, + "comment": null + }, + { + "name": "session", + "schema": "public", + "columns": { + "created_at": { + "name": "created_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "id": { + "name": "id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "token": { + "name": "token", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": true, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + } + }, + "indexes": [ + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "session_pkey", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "token" + ], + "composite": false, + "constraint": true, + "keyName": "session_token_unique", + "primary": false, + "unique": true + }, + { + "columnNames": [ + "user_id" + ], + "composite": false, + "constraint": false, + "keyName": "session_user_id_index", + "primary": false, + "unique": false + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "session_user_id_foreign": { + "columnNames": [ + "user_id" + ], + "constraintName": "session_user_id_foreign", + "localTableName": "public.session", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.user", + "deleteRule": "cascade" + } + }, + "comment": null + }, + { + "name": "teams_invite", + "schema": "public", + "columns": { + "created_at": { + "name": "created_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "id": { + "name": "id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "invitee_auth_id": { + "name": "invitee_auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "status": { + "name": "status", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "'pending'", + "comment": null, + "collation": null, + "enumItems": [ + "pending", + "accepted", + "declined" + ], + "mappedType": "enum" + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + } + }, + "indexes": [ + { + "columnNames": [ + "invitee_auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "teams_invite_invitee_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "teams_invite_pkey", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "team_id" + ], + "composite": false, + "constraint": false, + "keyName": "teams_invite_team_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "team_id", + "invitee_auth_id" + ], + "composite": true, + "constraint": false, + "keyName": "teams_invite_team_id_invitee_auth_id_index", + "primary": false, + "unique": false + } + ], + "checks": [ + { + "name": "teams_invite_status_check", + "expression": "\"status\" in ('pending', 'accepted', 'declined')", + "definition": "check (\"status\" in ('pending', 'accepted', 'declined'))", + "columnName": "status" + } + ], + "triggers": [], + "foreignKeys": { + "teams_invite_invitee_auth_id_foreign": { + "columnNames": [ + "invitee_auth_id" + ], + "constraintName": "teams_invite_invitee_auth_id_foreign", + "localTableName": "public.teams_invite", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "public.core_user", + "deleteRule": "cascade" + }, + "teams_invite_team_id_foreign": { + "columnNames": [ + "team_id" + ], + "constraintName": "teams_invite_team_id_foreign", + "localTableName": "public.teams_invite", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.teams_team", + "deleteRule": "cascade" + } + }, + "comment": null + }, + { + "name": "teams_member", + "schema": "public", + "columns": { + "auth_id": { + "name": "auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": true, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "id": { + "name": "id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + } + }, + "indexes": [ + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "teams_member_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": true, + "keyName": "teams_member_auth_id_unique", + "primary": false, + "unique": true + }, + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "teams_member_pkey", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "team_id" + ], + "composite": false, + "constraint": false, + "keyName": "teams_member_team_id_index", + "primary": false, + "unique": false + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "teams_member_auth_id_foreign": { + "columnNames": [ + "auth_id" + ], + "constraintName": "teams_member_auth_id_foreign", + "localTableName": "public.teams_member", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "public.core_user", + "deleteRule": "cascade" + }, + "teams_member_team_id_foreign": { + "columnNames": [ + "team_id" + ], + "constraintName": "teams_member_team_id_foreign", + "localTableName": "public.teams_member", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.teams_team", + "deleteRule": "cascade" + } + }, + "comment": null + }, + { + "name": "teams_team", + "schema": "public", + "columns": { + "created_at": { + "name": "created_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "id": { + "name": "id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "owner_auth_id": { + "name": "owner_auth_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "tag": { + "name": "tag", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": true, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + } + }, + "indexes": [ + { + "columnNames": [ + "owner_auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "teams_team_owner_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "teams_team_pkey", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "tag" + ], + "composite": false, + "constraint": false, + "keyName": "teams_team_tag_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "tag" + ], + "composite": false, + "constraint": true, + "keyName": "teams_team_tag_unique", + "primary": false, + "unique": true + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "teams_team_owner_auth_id_foreign": { + "columnNames": [ + "owner_auth_id" + ], + "constraintName": "teams_team_owner_auth_id_foreign", + "localTableName": "public.teams_team", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "public.core_user", + "deleteRule": "cascade" + } + }, + "comment": null + }, + { + "name": "user", + "schema": "public", + "columns": { + "created_at": { + "name": "created_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": true, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "false", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "boolean" + }, + "id": { + "name": "id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "image": { + "name": "image", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "indexes": [ + { + "columnNames": [ + "email" + ], + "composite": false, + "constraint": true, + "keyName": "user_email_unique", + "primary": false, + "unique": true + }, + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "user_pkey", + "primary": true, + "unique": true + } + ], + "checks": [], + "triggers": [], + "foreignKeys": {}, + "comment": null + }, + { + "name": "verification", + "schema": "public", + "columns": { + "created_at": { + "name": "created_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "id": { + "name": "id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "value": { + "name": "value", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + } + }, + "indexes": [ + { + "columnNames": [ + "identifier" + ], + "composite": false, + "constraint": false, + "keyName": "verification_identifier_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "verification_pkey", + "primary": true, + "unique": true + } + ], + "checks": [], + "triggers": [], + "foreignKeys": {}, + "comment": null + } + ], + "views": [], + "nativeEnums": {} +} \ No newline at end of file diff --git a/apps/web/db/migrations/postgresql/Migration20260927180400_initial.ts b/apps/web/db/migrations/postgresql/Migration20260927180400_initial.ts new file mode 100644 index 00000000..5e51d2e8 --- /dev/null +++ b/apps/web/db/migrations/postgresql/Migration20260927180400_initial.ts @@ -0,0 +1,210 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20260927180400_initial extends Migration { + + override name = 'Migration20260927180400_initial'; + + override up(): void | Promise { + this.addSql(`create table "user" ("id" varchar(255) not null, "name" varchar(255) not null, "email" varchar(255) not null, "email_verified" boolean not null default false, "image" text null, "created_at" timestamptz not null, "updated_at" timestamptz not null, primary key ("id"));`); + this.addSql(`alter table "user" add constraint "user_email_unique" unique ("email");`); + + this.addSql(`create table "session" ("id" varchar(255) not null, "expires_at" timestamptz not null, "token" varchar(255) not null, "created_at" timestamptz not null, "updated_at" timestamptz not null, "ip_address" varchar(255) null, "user_agent" text null, "user_id" varchar(255) not null, primary key ("id"));`); + this.addSql(`alter table "session" add constraint "session_token_unique" unique ("token");`); + this.addSql(`create index "session_user_id_index" on "session" ("user_id");`); + + this.addSql(`create table "account" ("id" varchar(255) not null, "account_id" varchar(255) not null, "provider_id" varchar(255) not null, "user_id" varchar(255) not null, "access_token" text null, "refresh_token" text null, "id_token" text null, "access_token_expires_at" timestamptz null, "refresh_token_expires_at" timestamptz null, "scope" text null, "password" varchar(255) null, "created_at" timestamptz not null, "updated_at" timestamptz not null, primary key ("id"));`); + this.addSql(`create index "account_user_id_index" on "account" ("user_id");`); + + this.addSql(`create table "verification" ("id" varchar(255) not null, "identifier" varchar(255) not null, "value" text not null, "expires_at" timestamptz not null, "created_at" timestamptz null, "updated_at" timestamptz null, primary key ("id"));`); + this.addSql(`create index "verification_identifier_index" on "verification" ("identifier");`); + + this.addSql(`create table "core_event" ("id" varchar(255) not null, "title" varchar(255) not null, "start_time" timestamptz not null, "end_time" timestamptz not null, "location" varchar(255) not null default 'TBD', "description" text not null, "type" varchar(255) not null, "host" varchar(255) null, "hidden" boolean not null default false, "created_at" timestamptz not null, "updated_at" timestamptz not null, primary key ("id"));`); + this.addSql(`create index "core_event_start_time_index" on "core_event" ("start_time");`); + this.addSql(`create index "core_event_type_index" on "core_event" ("type");`); + this.addSql(`create index "core_event_hidden_index" on "core_event" ("hidden");`); + + this.addSql(`create table "core_operation_lock" ("key" varchar(255) not null, "token" varchar(255) not null, primary key ("key"));`); + + this.addSql(`create table "core_role" ("id" varchar(255) not null, "name" varchar(255) not null, "position" int not null, "permissions" jsonb not null default '[]', "color" varchar(255) null, "created_at" timestamptz not null, "updated_at" timestamptz not null, primary key ("id"));`); + this.addSql(`alter table "core_role" add constraint "core_role_name_unique" unique ("name");`); + this.addSql(`create index "core_role_position_index" on "core_role" ("position");`); + + this.addSql(`create table "core_user" ("auth_id" varchar(255) not null, "first_name" varchar(255) not null, "last_name" varchar(255) not null, "profile_photo_url" text null, "hack_tag" varchar(255) null, "bio" text null, "pronouns" varchar(255) null, "skills" jsonb not null default '[]', "is_profile_searchable" boolean not null default true, "discord_display_handle" varchar(255) null, "role_id" varchar(255) null, "is_approved" boolean not null default false, "checked_in_at" timestamptz null, "created_at" timestamptz not null, "updated_at" timestamptz not null, primary key ("auth_id"));`); + this.addSql(`alter table "core_user" add constraint "core_user_hack_tag_unique" unique ("hack_tag");`); + this.addSql(`create index "core_user_hack_tag_index" on "core_user" ("hack_tag");`); + this.addSql(`create index "core_user_role_id_index" on "core_user" ("role_id");`); + this.addSql(`create index "core_user_created_at_index" on "core_user" ("created_at");`); + + this.addSql(`create table "discord_verification" ("code" varchar(255) not null, "discord_user_id" varchar(255) not null, "guild_id" varchar(255) not null, "username" varchar(255) not null, "avatar_hash" varchar(255) null, "auth_id" varchar(255) null, "status" text not null default 'pending', "expires_at" timestamptz not null, "created_at" timestamptz not null, "accepted_at" timestamptz null, primary key ("code"));`); + this.addSql(`create index "discord_verification_discord_user_id_index" on "discord_verification" ("discord_user_id");`); + this.addSql(`create index "discord_verification_guild_id_index" on "discord_verification" ("guild_id");`); + this.addSql(`create index "discord_verification_auth_id_index" on "discord_verification" ("auth_id");`); + this.addSql(`create index "discord_verification_status_index" on "discord_verification" ("status");`); + this.addSql(`create index "discord_verification_created_at_index" on "discord_verification" ("created_at");`); + + this.addSql(`create table "discord_role_sync_attempt" ("id" varchar(255) not null, "auth_id" varchar(255) not null, "discord_user_id" varchar(255) not null, "guild_id" varchar(255) not null, "status" text not null, "role_ids" jsonb not null default '[]', "role_names" jsonb not null default '[]', "error" text null, "created_at" timestamptz not null, primary key ("id"));`); + this.addSql(`create index "discord_role_sync_attempt_auth_id_index" on "discord_role_sync_attempt" ("auth_id");`); + this.addSql(`create index "discord_role_sync_attempt_discord_user_id_index" on "discord_role_sync_attempt" ("discord_user_id");`); + this.addSql(`create index "discord_role_sync_attempt_status_index" on "discord_role_sync_attempt" ("status");`); + this.addSql(`create index "discord_role_sync_attempt_created_at_index" on "discord_role_sync_attempt" ("created_at");`); + + this.addSql(`create table "discord_member" ("auth_id" varchar(255) not null, "discord_user_id" varchar(255) not null, "guild_id" varchar(255) not null, "username" varchar(255) not null, "avatar_hash" varchar(255) null, "verified_at" timestamptz not null, "updated_at" timestamptz not null, "last_role_sync_at" timestamptz null, primary key ("auth_id"));`); + this.addSql(`alter table "discord_member" add constraint "discord_member_discord_user_id_unique" unique ("discord_user_id");`); + this.addSql(`create index "discord_member_discord_user_id_index" on "discord_member" ("discord_user_id");`); + this.addSql(`create index "discord_member_guild_id_index" on "discord_member" ("guild_id");`); + this.addSql(`create index "discord_member_updated_at_index" on "discord_member" ("updated_at");`); + + this.addSql(`create table "core_user_data" ("auth_id" varchar(255) not null, "age" int not null, "gender" varchar(255) not null, "race" varchar(255) not null, "ethnicity" varchar(255) not null, "shirt_size" varchar(255) not null, "dietary_restrictions" jsonb not null default '[]', "accommodation_note" text null, "phone_number" varchar(255) null, "country_of_residence" varchar(255) null, "has_accepted_mlhcode_of_conduct" boolean not null, "has_shared_data_with_mlh" boolean not null, "is_emailable" boolean not null, "completed_at" timestamptz not null, "updated_at" timestamptz not null, primary key ("auth_id"));`); + + this.addSql(`create table "core_user_ban" ("auth_id" varchar(255) not null, "reason" varchar(255) null, "banned_by_auth_id" varchar(255) not null, "created_at" timestamptz not null, primary key ("auth_id"));`); + + this.addSql(`create table "core_setting" ("key" varchar(255) not null, "value" jsonb not null, "created_at" timestamptz not null, "created_by_auth_id" varchar(255) null, "updated_at" timestamptz not null, "updated_by_auth_id" varchar(255) null, primary key ("key"));`); + this.addSql(`create index "core_setting_updated_at_index" on "core_setting" ("updated_at");`); + this.addSql(`create index "core_setting_updated_by_auth_id_index" on "core_setting" ("updated_by_auth_id");`); + + this.addSql(`create table "core_rsvp" ("auth_id" varchar(255) not null, "status" text not null, "waitlist_position" int null, "created_at" timestamptz not null, "updated_at" timestamptz not null, "confirmed_at" timestamptz null, "waitlisted_at" timestamptz null, "cancelled_at" timestamptz null, "cancelled_by_auth_id" varchar(255) null, "promoted_at" timestamptz null, "promoted_by_auth_id" varchar(255) null, primary key ("auth_id"));`); + this.addSql(`create index "core_rsvp_status_index" on "core_rsvp" ("status");`); + this.addSql(`create index "core_rsvp_waitlist_position_index" on "core_rsvp" ("waitlist_position");`); + this.addSql(`create index "core_rsvp_created_at_index" on "core_rsvp" ("created_at");`); + this.addSql(`create index "core_rsvp_updated_at_index" on "core_rsvp" ("updated_at");`); + + this.addSql(`create table "core_notification_intent" ("id" varchar(255) not null, "kind" varchar(255) not null, "recipient_auth_id" varchar(255) null, "payload" jsonb not null, "status" text not null default 'pending', "idempotency_key" varchar(255) null, "created_at" timestamptz not null, "updated_at" timestamptz not null, primary key ("id"));`); + this.addSql(`alter table "core_notification_intent" add constraint "core_notification_intent_idempotency_key_unique" unique ("idempotency_key");`); + this.addSql(`create index "core_notification_intent_kind_index" on "core_notification_intent" ("kind");`); + this.addSql(`create index "core_notification_intent_recipient_auth_id_index" on "core_notification_intent" ("recipient_auth_id");`); + this.addSql(`create index "core_notification_intent_status_index" on "core_notification_intent" ("status");`); + this.addSql(`create index "core_notification_intent_created_at_index" on "core_notification_intent" ("created_at");`); + + this.addSql(`create table "core_notification_delivery_attempt" ("id" varchar(255) not null, "intent_id" varchar(255) not null, "channel" varchar(255) not null, "provider" varchar(255) null, "status" text not null, "recipient" varchar(255) null, "external_id" varchar(255) null, "error" text null, "metadata" jsonb not null, "attempted_at" timestamptz not null, primary key ("id"));`); + this.addSql(`create index "core_notification_delivery_attempt_intent_id_index" on "core_notification_delivery_attempt" ("intent_id");`); + this.addSql(`create index "core_notification_delivery_attempt_channel_index" on "core_notification_delivery_attempt" ("channel");`); + this.addSql(`create index "core_notification_delivery_attempt_status_index" on "core_notification_delivery_attempt" ("status");`); + this.addSql(`create index "core_notification_delivery_attempt_attempted_at_index" on "core_notification_delivery_attempt" ("attempted_at");`); + + this.addSql(`create table "core_hacker" ("auth_id" varchar(255) not null, "university" varchar(255) not null, "major" varchar(255) not null, "school_id" varchar(255) null, "level_of_study" varchar(255) not null, "hackathons_attended" int not null, "software_experience" varchar(255) not null, "heard_from" varchar(255) null, "github_url" text null, "linked_in_url" text null, "personal_website_url" text null, "resume_url" text null, "group" varchar(255) null, "registered_at" timestamptz not null, "updated_at" timestamptz not null, primary key ("auth_id"));`); + this.addSql(`create index "core_hacker_registered_at_index" on "core_hacker" ("registered_at");`); + + this.addSql(`create table "core_event_scan" ("id" varchar(255) not null, "event_id" varchar(255) not null, "auth_id" varchar(255) not null, "scanned_by_auth_id" varchar(255) not null, "scanned_at" timestamptz not null, primary key ("id"));`); + this.addSql(`create index "core_event_scan_event_id_index" on "core_event_scan" ("event_id");`); + this.addSql(`create index "core_event_scan_auth_id_index" on "core_event_scan" ("auth_id");`); + this.addSql(`create index "core_event_scan_event_id_auth_id_index" on "core_event_scan" ("event_id", "auth_id");`); + this.addSql(`create index "core_event_scan_scanned_at_index" on "core_event_scan" ("scanned_at");`); + + this.addSql(`create table "teams_team" ("id" varchar(255) not null, "name" varchar(255) not null, "tag" varchar(255) not null, "owner_auth_id" varchar(255) not null, "created_at" timestamptz not null, primary key ("id"));`); + this.addSql(`alter table "teams_team" add constraint "teams_team_tag_unique" unique ("tag");`); + this.addSql(`create index "teams_team_tag_index" on "teams_team" ("tag");`); + this.addSql(`create index "teams_team_owner_auth_id_index" on "teams_team" ("owner_auth_id");`); + + this.addSql(`create table "teams_member" ("id" varchar(255) not null, "team_id" varchar(255) not null, "auth_id" varchar(255) not null, "joined_at" timestamptz not null, primary key ("id"));`); + this.addSql(`alter table "teams_member" add constraint "teams_member_auth_id_unique" unique ("auth_id");`); + this.addSql(`create index "teams_member_team_id_index" on "teams_member" ("team_id");`); + this.addSql(`create index "teams_member_auth_id_index" on "teams_member" ("auth_id");`); + + this.addSql(`create table "teams_invite" ("id" varchar(255) not null, "team_id" varchar(255) not null, "invitee_auth_id" varchar(255) not null, "status" text not null default 'pending', "created_at" timestamptz not null, primary key ("id"));`); + this.addSql(`create index "teams_invite_team_id_index" on "teams_invite" ("team_id");`); + this.addSql(`create index "teams_invite_invitee_auth_id_index" on "teams_invite" ("invitee_auth_id");`); + this.addSql(`create index "teams_invite_team_id_invitee_auth_id_index" on "teams_invite" ("team_id", "invitee_auth_id");`); + + this.addSql(`alter table "session" add constraint "session_user_id_foreign" foreign key ("user_id") references "user" ("id") on delete cascade;`); + + this.addSql(`alter table "account" add constraint "account_user_id_foreign" foreign key ("user_id") references "user" ("id") on delete cascade;`); + + this.addSql(`alter table "core_user" add constraint "core_user_auth_id_foreign" foreign key ("auth_id") references "user" ("id") on update cascade on delete cascade;`); + this.addSql(`alter table "core_user" add constraint "core_user_role_id_foreign" foreign key ("role_id") references "core_role" ("id") on delete set null;`); + + this.addSql(`alter table "discord_verification" add constraint "discord_verification_auth_id_foreign" foreign key ("auth_id") references "core_user" ("auth_id") on delete set null;`); + this.addSql(`alter table "discord_verification" add constraint "discord_verification_status_check" check ("status" in ('pending', 'accepted', 'rejected', 'expired'));`); + + this.addSql(`alter table "discord_role_sync_attempt" add constraint "discord_role_sync_attempt_auth_id_foreign" foreign key ("auth_id") references "core_user" ("auth_id") on delete cascade;`); + this.addSql(`alter table "discord_role_sync_attempt" add constraint "discord_role_sync_attempt_status_check" check ("status" in ('synced', 'failed', 'skipped'));`); + + this.addSql(`alter table "discord_member" add constraint "discord_member_auth_id_foreign" foreign key ("auth_id") references "core_user" ("auth_id") on update cascade on delete cascade;`); + + this.addSql(`alter table "core_user_data" add constraint "core_user_data_auth_id_foreign" foreign key ("auth_id") references "core_user" ("auth_id") on update cascade on delete cascade;`); + + this.addSql(`alter table "core_user_ban" add constraint "core_user_ban_auth_id_foreign" foreign key ("auth_id") references "core_user" ("auth_id") on update cascade on delete cascade;`); + this.addSql(`alter table "core_user_ban" add constraint "core_user_ban_banned_by_auth_id_foreign" foreign key ("banned_by_auth_id") references "core_user" ("auth_id");`); + + this.addSql(`alter table "core_setting" add constraint "core_setting_created_by_auth_id_foreign" foreign key ("created_by_auth_id") references "core_user" ("auth_id") on delete set null;`); + this.addSql(`alter table "core_setting" add constraint "core_setting_updated_by_auth_id_foreign" foreign key ("updated_by_auth_id") references "core_user" ("auth_id") on delete set null;`); + + this.addSql(`alter table "core_rsvp" add constraint "core_rsvp_auth_id_foreign" foreign key ("auth_id") references "core_user" ("auth_id") on update cascade on delete cascade;`); + this.addSql(`alter table "core_rsvp" add constraint "core_rsvp_cancelled_by_auth_id_foreign" foreign key ("cancelled_by_auth_id") references "core_user" ("auth_id") on delete set null;`); + this.addSql(`alter table "core_rsvp" add constraint "core_rsvp_promoted_by_auth_id_foreign" foreign key ("promoted_by_auth_id") references "core_user" ("auth_id") on delete set null;`); + this.addSql(`alter table "core_rsvp" add constraint "core_rsvp_status_check" check ("status" in ('confirmed', 'waitlisted', 'cancelled'));`); + + this.addSql(`alter table "core_notification_intent" add constraint "core_notification_intent_recipient_auth_id_foreign" foreign key ("recipient_auth_id") references "core_user" ("auth_id") on delete set null;`); + this.addSql(`alter table "core_notification_intent" add constraint "core_notification_intent_status_check" check ("status" in ('pending', 'processing', 'delivered', 'failed', 'skipped'));`); + + this.addSql(`alter table "core_notification_delivery_attempt" add constraint "core_notification_delivery_attempt_intent_id_foreign" foreign key ("intent_id") references "core_notification_intent" ("id") on delete cascade;`); + this.addSql(`alter table "core_notification_delivery_attempt" add constraint "core_notification_delivery_attempt_status_check" check ("status" in ('delivered', 'failed', 'skipped'));`); + + this.addSql(`alter table "core_hacker" add constraint "core_hacker_auth_id_foreign" foreign key ("auth_id") references "core_user" ("auth_id") on update cascade on delete cascade;`); + + this.addSql(`alter table "core_event_scan" add constraint "core_event_scan_event_id_foreign" foreign key ("event_id") references "core_event" ("id") on delete cascade;`); + this.addSql(`alter table "core_event_scan" add constraint "core_event_scan_auth_id_foreign" foreign key ("auth_id") references "core_user" ("auth_id") on delete cascade;`); + this.addSql(`alter table "core_event_scan" add constraint "core_event_scan_scanned_by_auth_id_foreign" foreign key ("scanned_by_auth_id") references "core_user" ("auth_id");`); + + this.addSql(`alter table "teams_team" add constraint "teams_team_owner_auth_id_foreign" foreign key ("owner_auth_id") references "core_user" ("auth_id") on delete cascade;`); + + this.addSql(`alter table "teams_member" add constraint "teams_member_team_id_foreign" foreign key ("team_id") references "teams_team" ("id") on delete cascade;`); + this.addSql(`alter table "teams_member" add constraint "teams_member_auth_id_foreign" foreign key ("auth_id") references "core_user" ("auth_id") on delete cascade;`); + + this.addSql(`alter table "teams_invite" add constraint "teams_invite_team_id_foreign" foreign key ("team_id") references "teams_team" ("id") on delete cascade;`); + this.addSql(`alter table "teams_invite" add constraint "teams_invite_invitee_auth_id_foreign" foreign key ("invitee_auth_id") references "core_user" ("auth_id") on delete cascade;`); + this.addSql(`alter table "teams_invite" add constraint "teams_invite_status_check" check ("status" in ('pending', 'accepted', 'declined'));`); + } + + override down(): void | Promise { + this.addSql(`alter table "session" drop constraint "session_user_id_foreign";`); + this.addSql(`alter table "account" drop constraint "account_user_id_foreign";`); + this.addSql(`alter table "core_user" drop constraint "core_user_auth_id_foreign";`); + this.addSql(`alter table "core_event_scan" drop constraint "core_event_scan_event_id_foreign";`); + this.addSql(`alter table "core_user" drop constraint "core_user_role_id_foreign";`); + this.addSql(`alter table "discord_verification" drop constraint "discord_verification_auth_id_foreign";`); + this.addSql(`alter table "discord_role_sync_attempt" drop constraint "discord_role_sync_attempt_auth_id_foreign";`); + this.addSql(`alter table "discord_member" drop constraint "discord_member_auth_id_foreign";`); + this.addSql(`alter table "core_user_data" drop constraint "core_user_data_auth_id_foreign";`); + this.addSql(`alter table "core_user_ban" drop constraint "core_user_ban_auth_id_foreign";`); + this.addSql(`alter table "core_user_ban" drop constraint "core_user_ban_banned_by_auth_id_foreign";`); + this.addSql(`alter table "core_setting" drop constraint "core_setting_created_by_auth_id_foreign";`); + this.addSql(`alter table "core_setting" drop constraint "core_setting_updated_by_auth_id_foreign";`); + this.addSql(`alter table "core_rsvp" drop constraint "core_rsvp_auth_id_foreign";`); + this.addSql(`alter table "core_rsvp" drop constraint "core_rsvp_cancelled_by_auth_id_foreign";`); + this.addSql(`alter table "core_rsvp" drop constraint "core_rsvp_promoted_by_auth_id_foreign";`); + this.addSql(`alter table "core_notification_intent" drop constraint "core_notification_intent_recipient_auth_id_foreign";`); + this.addSql(`alter table "core_hacker" drop constraint "core_hacker_auth_id_foreign";`); + this.addSql(`alter table "core_event_scan" drop constraint "core_event_scan_auth_id_foreign";`); + this.addSql(`alter table "core_event_scan" drop constraint "core_event_scan_scanned_by_auth_id_foreign";`); + this.addSql(`alter table "teams_team" drop constraint "teams_team_owner_auth_id_foreign";`); + this.addSql(`alter table "teams_member" drop constraint "teams_member_auth_id_foreign";`); + this.addSql(`alter table "teams_invite" drop constraint "teams_invite_invitee_auth_id_foreign";`); + this.addSql(`alter table "core_notification_delivery_attempt" drop constraint "core_notification_delivery_attempt_intent_id_foreign";`); + this.addSql(`alter table "teams_member" drop constraint "teams_member_team_id_foreign";`); + this.addSql(`alter table "teams_invite" drop constraint "teams_invite_team_id_foreign";`); + + this.addSql(`drop table if exists "user" cascade;`); + this.addSql(`drop table if exists "session" cascade;`); + this.addSql(`drop table if exists "account" cascade;`); + this.addSql(`drop table if exists "verification" cascade;`); + this.addSql(`drop table if exists "core_event" cascade;`); + this.addSql(`drop table if exists "core_operation_lock" cascade;`); + this.addSql(`drop table if exists "core_role" cascade;`); + this.addSql(`drop table if exists "core_user" cascade;`); + this.addSql(`drop table if exists "discord_verification" cascade;`); + this.addSql(`drop table if exists "discord_role_sync_attempt" cascade;`); + this.addSql(`drop table if exists "discord_member" cascade;`); + this.addSql(`drop table if exists "core_user_data" cascade;`); + this.addSql(`drop table if exists "core_user_ban" cascade;`); + this.addSql(`drop table if exists "core_setting" cascade;`); + this.addSql(`drop table if exists "core_rsvp" cascade;`); + this.addSql(`drop table if exists "core_notification_intent" cascade;`); + this.addSql(`drop table if exists "core_notification_delivery_attempt" cascade;`); + this.addSql(`drop table if exists "core_hacker" cascade;`); + this.addSql(`drop table if exists "core_event_scan" cascade;`); + this.addSql(`drop table if exists "teams_team" cascade;`); + this.addSql(`drop table if exists "teams_member" cascade;`); + this.addSql(`drop table if exists "teams_invite" cascade;`); + } + +} diff --git a/apps/web/db/migrations/sqlite/.snapshot.json b/apps/web/db/migrations/sqlite/.snapshot.json new file mode 100644 index 00000000..598f07c6 --- /dev/null +++ b/apps/web/db/migrations/sqlite/.snapshot.json @@ -0,0 +1,4661 @@ +{ + "namespaces": [], + "tables": [ + { + "name": "account", + "columns": { + "access_token": { + "name": "access_token", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "account_id": { + "name": "account_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "id_token": { + "name": "id_token", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "password": { + "name": "password", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "scope": { + "name": "scope", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "user_id": { + "name": "user_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + } + }, + "indexes": [ + { + "columnNames": [ + "user_id" + ], + "composite": false, + "constraint": false, + "keyName": "account_user_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "primary", + "primary": true, + "unique": true + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "account_user_id_foreign": { + "columnNames": [ + "user_id" + ], + "constraintName": "account_user_id_foreign", + "localTableName": "account", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "user", + "deleteRule": "cascade" + } + }, + "comment": null + }, + { + "name": "core_event", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "description": { + "name": "description", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "end_time": { + "name": "end_time", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "hidden": { + "name": "hidden", + "type": "integer", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "false", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "integer" + }, + "host": { + "name": "host", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "location": { + "name": "location", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "'TBD'", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "start_time": { + "name": "start_time", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "title": { + "name": "title", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "type": { + "name": "type", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "indexes": [ + { + "columnNames": [ + "hidden" + ], + "composite": false, + "constraint": false, + "keyName": "core_event_hidden_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "start_time" + ], + "composite": false, + "constraint": false, + "keyName": "core_event_start_time_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "type" + ], + "composite": false, + "constraint": false, + "keyName": "core_event_type_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "primary", + "primary": true, + "unique": true + } + ], + "checks": [], + "triggers": [], + "foreignKeys": {}, + "comment": null + }, + { + "name": "core_event_scan", + "columns": { + "auth_id": { + "name": "auth_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "event_id": { + "name": "event_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "scanned_at": { + "name": "scanned_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "scanned_by_auth_id": { + "name": "scanned_by_auth_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + } + }, + "indexes": [ + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_event_scan_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "event_id", + "auth_id" + ], + "composite": true, + "constraint": false, + "keyName": "core_event_scan_event_id_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "event_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_event_scan_event_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "scanned_at" + ], + "composite": false, + "constraint": false, + "keyName": "core_event_scan_scanned_at_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "scanned_by_auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_event_scan_scanned_by_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "primary", + "primary": true, + "unique": true + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "core_event_scan_auth_id_foreign": { + "columnNames": [ + "auth_id" + ], + "constraintName": "core_event_scan_auth_id_foreign", + "localTableName": "core_event_scan", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user", + "deleteRule": "cascade" + }, + "core_event_scan_event_id_foreign": { + "columnNames": [ + "event_id" + ], + "constraintName": "core_event_scan_event_id_foreign", + "localTableName": "core_event_scan", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "core_event", + "deleteRule": "cascade" + }, + "core_event_scan_scanned_by_auth_id_foreign": { + "columnNames": [ + "scanned_by_auth_id" + ], + "constraintName": "core_event_scan_scanned_by_auth_id_foreign", + "localTableName": "core_event_scan", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user" + } + }, + "comment": null + }, + { + "name": "core_hacker", + "columns": { + "auth_id": { + "name": "auth_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "github_url": { + "name": "github_url", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "group": { + "name": "group", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "hackathons_attended": { + "name": "hackathons_attended", + "type": "integer", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "integer" + }, + "heard_from": { + "name": "heard_from", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "level_of_study": { + "name": "level_of_study", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "linked_in_url": { + "name": "linked_in_url", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "major": { + "name": "major", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "personal_website_url": { + "name": "personal_website_url", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "registered_at": { + "name": "registered_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "resume_url": { + "name": "resume_url", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "school_id": { + "name": "school_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "software_experience": { + "name": "software_experience", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "university": { + "name": "university", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "indexes": [ + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_hacker_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "registered_at" + ], + "composite": false, + "constraint": false, + "keyName": "core_hacker_registered_at_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": true, + "keyName": "primary", + "primary": true, + "unique": true + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "core_hacker_auth_id_foreign": { + "columnNames": [ + "auth_id" + ], + "constraintName": "core_hacker_auth_id_foreign", + "localTableName": "core_hacker", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user", + "updateRule": "cascade", + "deleteRule": "cascade" + } + }, + "comment": null + }, + { + "name": "core_notification_delivery_attempt", + "columns": { + "attempted_at": { + "name": "attempted_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "channel": { + "name": "channel", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "error": { + "name": "error", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "external_id": { + "name": "external_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "intent_id": { + "name": "intent_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "metadata": { + "name": "metadata", + "type": "json", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "json" + }, + "provider": { + "name": "provider", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "recipient": { + "name": "recipient", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "status": { + "name": "status", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [ + "delivered", + "failed", + "skipped" + ], + "mappedType": "enum" + } + }, + "indexes": [ + { + "columnNames": [ + "attempted_at" + ], + "composite": false, + "constraint": false, + "keyName": "core_notification_delivery_attempt_attempted_at_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "channel" + ], + "composite": false, + "constraint": false, + "keyName": "core_notification_delivery_attempt_channel_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "intent_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_notification_delivery_attempt_intent_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "status" + ], + "composite": false, + "constraint": false, + "keyName": "core_notification_delivery_attempt_status_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "primary", + "primary": true, + "unique": true + } + ], + "checks": [ + { + "name": "core_notification_delivery_attempt_status_check", + "expression": "`status` in ('delivered', 'failed', 'skipped')", + "definition": "check (`status` in ('delivered', 'failed', 'skipped'))", + "columnName": "status" + } + ], + "triggers": [], + "foreignKeys": { + "core_notification_delivery_attempt_intent_id_foreign": { + "columnNames": [ + "intent_id" + ], + "constraintName": "core_notification_delivery_attempt_intent_id_foreign", + "localTableName": "core_notification_delivery_attempt", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "core_notification_intent", + "deleteRule": "cascade" + } + }, + "comment": null + }, + { + "name": "core_notification_intent", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": true, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "kind": { + "name": "kind", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "payload": { + "name": "payload", + "type": "json", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "json" + }, + "recipient_auth_id": { + "name": "recipient_auth_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "status": { + "name": "status", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "'pending'", + "comment": null, + "collation": null, + "enumItems": [ + "pending", + "processing", + "delivered", + "failed", + "skipped" + ], + "mappedType": "enum" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "indexes": [ + { + "columnNames": [ + "created_at" + ], + "composite": false, + "constraint": false, + "keyName": "core_notification_intent_created_at_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "idempotency_key" + ], + "composite": false, + "constraint": true, + "keyName": "core_notification_intent_idempotency_key_unique", + "primary": false, + "unique": true + }, + { + "columnNames": [ + "kind" + ], + "composite": false, + "constraint": false, + "keyName": "core_notification_intent_kind_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "recipient_auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_notification_intent_recipient_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "status" + ], + "composite": false, + "constraint": false, + "keyName": "core_notification_intent_status_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "primary", + "primary": true, + "unique": true + } + ], + "checks": [ + { + "name": "core_notification_intent_status_check", + "expression": "`status` in ('pending', 'processing', 'delivered', 'failed', 'skipped')", + "definition": "check (`status` in ('pending', 'processing', 'delivered', 'failed', 'skipped'))", + "columnName": "status" + } + ], + "triggers": [], + "foreignKeys": { + "core_notification_intent_recipient_auth_id_foreign": { + "columnNames": [ + "recipient_auth_id" + ], + "constraintName": "core_notification_intent_recipient_auth_id_foreign", + "localTableName": "core_notification_intent", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user", + "deleteRule": "set null" + } + }, + "comment": null + }, + { + "name": "core_operation_lock", + "columns": { + "key": { + "name": "key", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "token": { + "name": "token", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + } + }, + "indexes": [ + { + "columnNames": [ + "key" + ], + "composite": false, + "constraint": true, + "keyName": "primary", + "primary": true, + "unique": true + } + ], + "checks": [], + "triggers": [], + "foreignKeys": {}, + "comment": null + }, + { + "name": "core_role", + "columns": { + "color": { + "name": "color", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "name": { + "name": "name", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": true, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "permissions": { + "name": "permissions", + "type": "json", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "'[]'", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "json" + }, + "position": { + "name": "position", + "type": "integer", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "integer" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "indexes": [ + { + "columnNames": [ + "name" + ], + "composite": false, + "constraint": true, + "keyName": "core_role_name_unique", + "primary": false, + "unique": true + }, + { + "columnNames": [ + "position" + ], + "composite": false, + "constraint": false, + "keyName": "core_role_position_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "primary", + "primary": true, + "unique": true + } + ], + "checks": [], + "triggers": [], + "foreignKeys": {}, + "comment": null + }, + { + "name": "core_rsvp", + "columns": { + "auth_id": { + "name": "auth_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "cancelled_by_auth_id": { + "name": "cancelled_by_auth_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "promoted_at": { + "name": "promoted_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "promoted_by_auth_id": { + "name": "promoted_by_auth_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "status": { + "name": "status", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [ + "confirmed", + "waitlisted", + "cancelled" + ], + "mappedType": "enum" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "waitlist_position": { + "name": "waitlist_position", + "type": "integer", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "integer" + }, + "waitlisted_at": { + "name": "waitlisted_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "indexes": [ + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_rsvp_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "cancelled_by_auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_rsvp_cancelled_by_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "created_at" + ], + "composite": false, + "constraint": false, + "keyName": "core_rsvp_created_at_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "promoted_by_auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_rsvp_promoted_by_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "status" + ], + "composite": false, + "constraint": false, + "keyName": "core_rsvp_status_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "updated_at" + ], + "composite": false, + "constraint": false, + "keyName": "core_rsvp_updated_at_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "waitlist_position" + ], + "composite": false, + "constraint": false, + "keyName": "core_rsvp_waitlist_position_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": true, + "keyName": "primary", + "primary": true, + "unique": true + } + ], + "checks": [ + { + "name": "core_rsvp_status_check", + "expression": "`status` in ('confirmed', 'waitlisted', 'cancelled')", + "definition": "check (`status` in ('confirmed', 'waitlisted', 'cancelled'))", + "columnName": "status" + } + ], + "triggers": [], + "foreignKeys": { + "core_rsvp_auth_id_foreign": { + "columnNames": [ + "auth_id" + ], + "constraintName": "core_rsvp_auth_id_foreign", + "localTableName": "core_rsvp", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user", + "updateRule": "cascade", + "deleteRule": "cascade" + }, + "core_rsvp_cancelled_by_auth_id_foreign": { + "columnNames": [ + "cancelled_by_auth_id" + ], + "constraintName": "core_rsvp_cancelled_by_auth_id_foreign", + "localTableName": "core_rsvp", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user", + "deleteRule": "set null" + }, + "core_rsvp_promoted_by_auth_id_foreign": { + "columnNames": [ + "promoted_by_auth_id" + ], + "constraintName": "core_rsvp_promoted_by_auth_id_foreign", + "localTableName": "core_rsvp", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user", + "deleteRule": "set null" + } + }, + "comment": null + }, + { + "name": "core_setting", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "created_by_auth_id": { + "name": "created_by_auth_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "key": { + "name": "key", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "updated_by_auth_id": { + "name": "updated_by_auth_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "value": { + "name": "value", + "type": "json", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "json" + } + }, + "indexes": [ + { + "columnNames": [ + "created_by_auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_setting_created_by_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "updated_at" + ], + "composite": false, + "constraint": false, + "keyName": "core_setting_updated_at_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "updated_by_auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_setting_updated_by_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "key" + ], + "composite": false, + "constraint": true, + "keyName": "primary", + "primary": true, + "unique": true + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "core_setting_created_by_auth_id_foreign": { + "columnNames": [ + "created_by_auth_id" + ], + "constraintName": "core_setting_created_by_auth_id_foreign", + "localTableName": "core_setting", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user", + "deleteRule": "set null" + }, + "core_setting_updated_by_auth_id_foreign": { + "columnNames": [ + "updated_by_auth_id" + ], + "constraintName": "core_setting_updated_by_auth_id_foreign", + "localTableName": "core_setting", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user", + "deleteRule": "set null" + } + }, + "comment": null + }, + { + "name": "core_user", + "columns": { + "auth_id": { + "name": "auth_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "bio": { + "name": "bio", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "checked_in_at": { + "name": "checked_in_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "discord_display_handle": { + "name": "discord_display_handle", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "first_name": { + "name": "first_name", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "hack_tag": { + "name": "hack_tag", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": true, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "is_approved": { + "name": "is_approved", + "type": "integer", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "false", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "integer" + }, + "is_profile_searchable": { + "name": "is_profile_searchable", + "type": "integer", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "true", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "integer" + }, + "last_name": { + "name": "last_name", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "profile_photo_url": { + "name": "profile_photo_url", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "pronouns": { + "name": "pronouns", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "role_id": { + "name": "role_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "skills": { + "name": "skills", + "type": "json", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "'[]'", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "json" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "indexes": [ + { + "columnNames": [ + "created_at" + ], + "composite": false, + "constraint": false, + "keyName": "core_user_created_at_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "hack_tag" + ], + "composite": false, + "constraint": false, + "keyName": "core_user_hack_tag_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "hack_tag" + ], + "composite": false, + "constraint": true, + "keyName": "core_user_hack_tag_unique", + "primary": false, + "unique": true + }, + { + "columnNames": [ + "role_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_user_role_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": true, + "keyName": "primary", + "primary": true, + "unique": true + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "core_user_auth_id_foreign": { + "columnNames": [ + "auth_id" + ], + "constraintName": "core_user_auth_id_foreign", + "localTableName": "core_user", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "user", + "updateRule": "cascade", + "deleteRule": "cascade" + }, + "core_user_role_id_foreign": { + "columnNames": [ + "role_id" + ], + "constraintName": "core_user_role_id_foreign", + "localTableName": "core_user", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "core_role", + "deleteRule": "set null" + } + }, + "comment": null + }, + { + "name": "core_user_ban", + "columns": { + "auth_id": { + "name": "auth_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "banned_by_auth_id": { + "name": "banned_by_auth_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "reason": { + "name": "reason", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + } + }, + "indexes": [ + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_user_ban_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "banned_by_auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_user_ban_banned_by_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": true, + "keyName": "primary", + "primary": true, + "unique": true + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "core_user_ban_auth_id_foreign": { + "columnNames": [ + "auth_id" + ], + "constraintName": "core_user_ban_auth_id_foreign", + "localTableName": "core_user_ban", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user", + "updateRule": "cascade", + "deleteRule": "cascade" + }, + "core_user_ban_banned_by_auth_id_foreign": { + "columnNames": [ + "banned_by_auth_id" + ], + "constraintName": "core_user_ban_banned_by_auth_id_foreign", + "localTableName": "core_user_ban", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user" + } + }, + "comment": null + }, + { + "name": "core_user_data", + "columns": { + "accommodation_note": { + "name": "accommodation_note", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "age": { + "name": "age", + "type": "integer", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "integer" + }, + "auth_id": { + "name": "auth_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "completed_at": { + "name": "completed_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "country_of_residence": { + "name": "country_of_residence", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "dietary_restrictions": { + "name": "dietary_restrictions", + "type": "json", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "'[]'", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "json" + }, + "ethnicity": { + "name": "ethnicity", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "gender": { + "name": "gender", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "has_accepted_mlhcode_of_conduct": { + "name": "has_accepted_mlhcode_of_conduct", + "type": "integer", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "integer" + }, + "has_shared_data_with_mlh": { + "name": "has_shared_data_with_mlh", + "type": "integer", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "integer" + }, + "is_emailable": { + "name": "is_emailable", + "type": "integer", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "integer" + }, + "phone_number": { + "name": "phone_number", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "race": { + "name": "race", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "shirt_size": { + "name": "shirt_size", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "indexes": [ + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "core_user_data_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": true, + "keyName": "primary", + "primary": true, + "unique": true + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "core_user_data_auth_id_foreign": { + "columnNames": [ + "auth_id" + ], + "constraintName": "core_user_data_auth_id_foreign", + "localTableName": "core_user_data", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user", + "updateRule": "cascade", + "deleteRule": "cascade" + } + }, + "comment": null + }, + { + "name": "discord_member", + "columns": { + "auth_id": { + "name": "auth_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "avatar_hash": { + "name": "avatar_hash", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "discord_user_id": { + "name": "discord_user_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": true, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "last_role_sync_at": { + "name": "last_role_sync_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "username": { + "name": "username", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "verified_at": { + "name": "verified_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "indexes": [ + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "discord_member_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "discord_user_id" + ], + "composite": false, + "constraint": false, + "keyName": "discord_member_discord_user_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "discord_user_id" + ], + "composite": false, + "constraint": true, + "keyName": "discord_member_discord_user_id_unique", + "primary": false, + "unique": true + }, + { + "columnNames": [ + "guild_id" + ], + "composite": false, + "constraint": false, + "keyName": "discord_member_guild_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "updated_at" + ], + "composite": false, + "constraint": false, + "keyName": "discord_member_updated_at_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": true, + "keyName": "primary", + "primary": true, + "unique": true + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "discord_member_auth_id_foreign": { + "columnNames": [ + "auth_id" + ], + "constraintName": "discord_member_auth_id_foreign", + "localTableName": "discord_member", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user", + "updateRule": "cascade", + "deleteRule": "cascade" + } + }, + "comment": null + }, + { + "name": "discord_role_sync_attempt", + "columns": { + "auth_id": { + "name": "auth_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "discord_user_id": { + "name": "discord_user_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "error": { + "name": "error", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "role_ids": { + "name": "role_ids", + "type": "json", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "'[]'", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "json" + }, + "role_names": { + "name": "role_names", + "type": "json", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "'[]'", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "json" + }, + "status": { + "name": "status", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [ + "synced", + "failed", + "skipped" + ], + "mappedType": "enum" + } + }, + "indexes": [ + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "discord_role_sync_attempt_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "created_at" + ], + "composite": false, + "constraint": false, + "keyName": "discord_role_sync_attempt_created_at_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "discord_user_id" + ], + "composite": false, + "constraint": false, + "keyName": "discord_role_sync_attempt_discord_user_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "status" + ], + "composite": false, + "constraint": false, + "keyName": "discord_role_sync_attempt_status_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "primary", + "primary": true, + "unique": true + } + ], + "checks": [ + { + "name": "discord_role_sync_attempt_status_check", + "expression": "`status` in ('synced', 'failed', 'skipped')", + "definition": "check (`status` in ('synced', 'failed', 'skipped'))", + "columnName": "status" + } + ], + "triggers": [], + "foreignKeys": { + "discord_role_sync_attempt_auth_id_foreign": { + "columnNames": [ + "auth_id" + ], + "constraintName": "discord_role_sync_attempt_auth_id_foreign", + "localTableName": "discord_role_sync_attempt", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user", + "deleteRule": "cascade" + } + }, + "comment": null + }, + { + "name": "discord_verification", + "columns": { + "accepted_at": { + "name": "accepted_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "auth_id": { + "name": "auth_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "avatar_hash": { + "name": "avatar_hash", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "code": { + "name": "code", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "discord_user_id": { + "name": "discord_user_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "status": { + "name": "status", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "'pending'", + "comment": null, + "collation": null, + "enumItems": [ + "pending", + "accepted", + "rejected", + "expired" + ], + "mappedType": "enum" + }, + "username": { + "name": "username", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + } + }, + "indexes": [ + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "discord_verification_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "created_at" + ], + "composite": false, + "constraint": false, + "keyName": "discord_verification_created_at_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "discord_user_id" + ], + "composite": false, + "constraint": false, + "keyName": "discord_verification_discord_user_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "guild_id" + ], + "composite": false, + "constraint": false, + "keyName": "discord_verification_guild_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "status" + ], + "composite": false, + "constraint": false, + "keyName": "discord_verification_status_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "code" + ], + "composite": false, + "constraint": true, + "keyName": "primary", + "primary": true, + "unique": true + } + ], + "checks": [ + { + "name": "discord_verification_status_check", + "expression": "`status` in ('pending', 'accepted', 'rejected', 'expired')", + "definition": "check (`status` in ('pending', 'accepted', 'rejected', 'expired'))", + "columnName": "status" + } + ], + "triggers": [], + "foreignKeys": { + "discord_verification_auth_id_foreign": { + "columnNames": [ + "auth_id" + ], + "constraintName": "discord_verification_auth_id_foreign", + "localTableName": "discord_verification", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user", + "deleteRule": "set null" + } + }, + "comment": null + }, + { + "name": "session", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "token": { + "name": "token", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": true, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "user_id": { + "name": "user_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + } + }, + "indexes": [ + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "primary", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "token" + ], + "composite": false, + "constraint": true, + "keyName": "session_token_unique", + "primary": false, + "unique": true + }, + { + "columnNames": [ + "user_id" + ], + "composite": false, + "constraint": false, + "keyName": "session_user_id_index", + "primary": false, + "unique": false + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "session_user_id_foreign": { + "columnNames": [ + "user_id" + ], + "constraintName": "session_user_id_foreign", + "localTableName": "session", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "user", + "deleteRule": "cascade" + } + }, + "comment": null + }, + { + "name": "teams_invite", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "invitee_auth_id": { + "name": "invitee_auth_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "status": { + "name": "status", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "'pending'", + "comment": null, + "collation": null, + "enumItems": [ + "pending", + "accepted", + "declined" + ], + "mappedType": "enum" + }, + "team_id": { + "name": "team_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + } + }, + "indexes": [ + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "primary", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "invitee_auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "teams_invite_invitee_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "team_id" + ], + "composite": false, + "constraint": false, + "keyName": "teams_invite_team_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "team_id", + "invitee_auth_id" + ], + "composite": true, + "constraint": false, + "keyName": "teams_invite_team_id_invitee_auth_id_index", + "primary": false, + "unique": false + } + ], + "checks": [ + { + "name": "teams_invite_status_check", + "expression": "`status` in ('pending', 'accepted', 'declined')", + "definition": "check (`status` in ('pending', 'accepted', 'declined'))", + "columnName": "status" + } + ], + "triggers": [], + "foreignKeys": { + "teams_invite_invitee_auth_id_foreign": { + "columnNames": [ + "invitee_auth_id" + ], + "constraintName": "teams_invite_invitee_auth_id_foreign", + "localTableName": "teams_invite", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user", + "deleteRule": "cascade" + }, + "teams_invite_team_id_foreign": { + "columnNames": [ + "team_id" + ], + "constraintName": "teams_invite_team_id_foreign", + "localTableName": "teams_invite", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "teams_team", + "deleteRule": "cascade" + } + }, + "comment": null + }, + { + "name": "teams_member", + "columns": { + "auth_id": { + "name": "auth_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": true, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "joined_at": { + "name": "joined_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "team_id": { + "name": "team_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + } + }, + "indexes": [ + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "primary", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "teams_member_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "auth_id" + ], + "composite": false, + "constraint": true, + "keyName": "teams_member_auth_id_unique", + "primary": false, + "unique": true + }, + { + "columnNames": [ + "team_id" + ], + "composite": false, + "constraint": false, + "keyName": "teams_member_team_id_index", + "primary": false, + "unique": false + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "teams_member_auth_id_foreign": { + "columnNames": [ + "auth_id" + ], + "constraintName": "teams_member_auth_id_foreign", + "localTableName": "teams_member", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user", + "deleteRule": "cascade" + }, + "teams_member_team_id_foreign": { + "columnNames": [ + "team_id" + ], + "constraintName": "teams_member_team_id_foreign", + "localTableName": "teams_member", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "teams_team", + "deleteRule": "cascade" + } + }, + "comment": null + }, + { + "name": "teams_team", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "name": { + "name": "name", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "owner_auth_id": { + "name": "owner_auth_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "tag": { + "name": "tag", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": true, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + } + }, + "indexes": [ + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "primary", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "owner_auth_id" + ], + "composite": false, + "constraint": false, + "keyName": "teams_team_owner_auth_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "tag" + ], + "composite": false, + "constraint": false, + "keyName": "teams_team_tag_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "tag" + ], + "composite": false, + "constraint": true, + "keyName": "teams_team_tag_unique", + "primary": false, + "unique": true + } + ], + "checks": [], + "triggers": [], + "foreignKeys": { + "teams_team_owner_auth_id_foreign": { + "columnNames": [ + "owner_auth_id" + ], + "constraintName": "teams_team_owner_auth_id_foreign", + "localTableName": "teams_team", + "referencedColumnNames": [ + "auth_id" + ], + "referencedTableName": "core_user", + "deleteRule": "cascade" + } + }, + "comment": null + }, + { + "name": "user", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "email": { + "name": "email", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": true, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "false", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "integer" + }, + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "image": { + "name": "image", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "name": { + "name": "name", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "indexes": [ + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "primary", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "email" + ], + "composite": false, + "constraint": true, + "keyName": "user_email_unique", + "primary": false, + "unique": true + } + ], + "checks": [], + "triggers": [], + "foreignKeys": {}, + "comment": null + }, + { + "name": "verification", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "identifier": { + "name": "identifier", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "value": { + "name": "value", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "text" + } + }, + "indexes": [ + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "primary", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "identifier" + ], + "composite": false, + "constraint": false, + "keyName": "verification_identifier_index", + "primary": false, + "unique": false + } + ], + "checks": [], + "triggers": [], + "foreignKeys": {}, + "comment": null + } + ], + "views": [], + "nativeEnums": {} +} \ No newline at end of file diff --git a/apps/web/db/migrations/sqlite/Migration20260927180400_initial.ts b/apps/web/db/migrations/sqlite/Migration20260927180400_initial.ts new file mode 100644 index 00000000..073a1114 --- /dev/null +++ b/apps/web/db/migrations/sqlite/Migration20260927180400_initial.ts @@ -0,0 +1,145 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20260927180400_initial extends Migration { + + override name = 'Migration20260927180400_initial'; + + override up(): void | Promise { + this.addSql(`create table \`user\` (\`id\` text not null primary key, \`name\` text not null, \`email\` text not null, \`email_verified\` integer not null default false, \`image\` text null, \`created_at\` datetime not null, \`updated_at\` datetime not null);`); + this.addSql(`create unique index \`user_email_unique\` on \`user\` (\`email\`);`); + + this.addSql(`create table \`session\` (\`id\` text not null primary key, \`expires_at\` datetime not null, \`token\` text not null, \`created_at\` datetime not null, \`updated_at\` datetime not null, \`ip_address\` text null, \`user_agent\` text null, \`user_id\` text not null, constraint \`session_user_id_foreign\` foreign key (\`user_id\`) references \`user\` (\`id\`) on delete cascade);`); + this.addSql(`create unique index \`session_token_unique\` on \`session\` (\`token\`);`); + this.addSql(`create index \`session_user_id_index\` on \`session\` (\`user_id\`);`); + + this.addSql(`create table \`account\` (\`id\` text not null primary key, \`account_id\` text not null, \`provider_id\` text not null, \`user_id\` text not null, \`access_token\` text null, \`refresh_token\` text null, \`id_token\` text null, \`access_token_expires_at\` datetime null, \`refresh_token_expires_at\` datetime null, \`scope\` text null, \`password\` text null, \`created_at\` datetime not null, \`updated_at\` datetime not null, constraint \`account_user_id_foreign\` foreign key (\`user_id\`) references \`user\` (\`id\`) on delete cascade);`); + this.addSql(`create index \`account_user_id_index\` on \`account\` (\`user_id\`);`); + + this.addSql(`create table \`verification\` (\`id\` text not null primary key, \`identifier\` text not null, \`value\` text not null, \`expires_at\` datetime not null, \`created_at\` datetime null, \`updated_at\` datetime null);`); + this.addSql(`create index \`verification_identifier_index\` on \`verification\` (\`identifier\`);`); + + this.addSql(`create table \`core_event\` (\`id\` text not null primary key, \`title\` text not null, \`start_time\` datetime not null, \`end_time\` datetime not null, \`location\` text not null default 'TBD', \`description\` text not null, \`type\` text not null, \`host\` text null, \`hidden\` integer not null default false, \`created_at\` datetime not null, \`updated_at\` datetime not null);`); + this.addSql(`create index \`core_event_start_time_index\` on \`core_event\` (\`start_time\`);`); + this.addSql(`create index \`core_event_type_index\` on \`core_event\` (\`type\`);`); + this.addSql(`create index \`core_event_hidden_index\` on \`core_event\` (\`hidden\`);`); + + this.addSql(`create table \`core_operation_lock\` (\`key\` text not null primary key, \`token\` text not null);`); + + this.addSql(`create table \`core_role\` (\`id\` text not null primary key, \`name\` text not null, \`position\` integer not null, \`permissions\` json not null default '[]', \`color\` text null, \`created_at\` datetime not null, \`updated_at\` datetime not null);`); + this.addSql(`create unique index \`core_role_name_unique\` on \`core_role\` (\`name\`);`); + this.addSql(`create index \`core_role_position_index\` on \`core_role\` (\`position\`);`); + + this.addSql(`create table \`core_user\` (\`auth_id\` text not null primary key, \`first_name\` text not null, \`last_name\` text not null, \`profile_photo_url\` text null, \`hack_tag\` text null, \`bio\` text null, \`pronouns\` text null, \`skills\` json not null default '[]', \`is_profile_searchable\` integer not null default true, \`discord_display_handle\` text null, \`role_id\` text null, \`is_approved\` integer not null default false, \`checked_in_at\` datetime null, \`created_at\` datetime not null, \`updated_at\` datetime not null, constraint \`core_user_auth_id_foreign\` foreign key (\`auth_id\`) references \`user\` (\`id\`) on update cascade on delete cascade, constraint \`core_user_role_id_foreign\` foreign key (\`role_id\`) references \`core_role\` (\`id\`) on delete set null);`); + this.addSql(`create unique index \`core_user_hack_tag_unique\` on \`core_user\` (\`hack_tag\`);`); + this.addSql(`create index \`core_user_hack_tag_index\` on \`core_user\` (\`hack_tag\`);`); + this.addSql(`create index \`core_user_role_id_index\` on \`core_user\` (\`role_id\`);`); + this.addSql(`create index \`core_user_created_at_index\` on \`core_user\` (\`created_at\`);`); + + this.addSql(`create table \`discord_verification\` (\`code\` text not null primary key, \`discord_user_id\` text not null, \`guild_id\` text not null, \`username\` text not null, \`avatar_hash\` text null, \`auth_id\` text null, \`status\` text check (\`status\` in ('pending', 'accepted', 'rejected', 'expired')) not null default 'pending', \`expires_at\` datetime not null, \`created_at\` datetime not null, \`accepted_at\` datetime null, constraint \`discord_verification_auth_id_foreign\` foreign key (\`auth_id\`) references \`core_user\` (\`auth_id\`) on delete set null);`); + this.addSql(`create index \`discord_verification_discord_user_id_index\` on \`discord_verification\` (\`discord_user_id\`);`); + this.addSql(`create index \`discord_verification_guild_id_index\` on \`discord_verification\` (\`guild_id\`);`); + this.addSql(`create index \`discord_verification_auth_id_index\` on \`discord_verification\` (\`auth_id\`);`); + this.addSql(`create index \`discord_verification_status_index\` on \`discord_verification\` (\`status\`);`); + this.addSql(`create index \`discord_verification_created_at_index\` on \`discord_verification\` (\`created_at\`);`); + + this.addSql(`create table \`discord_role_sync_attempt\` (\`id\` text not null primary key, \`auth_id\` text not null, \`discord_user_id\` text not null, \`guild_id\` text not null, \`status\` text check (\`status\` in ('synced', 'failed', 'skipped')) not null, \`role_ids\` json not null default '[]', \`role_names\` json not null default '[]', \`error\` text null, \`created_at\` datetime not null, constraint \`discord_role_sync_attempt_auth_id_foreign\` foreign key (\`auth_id\`) references \`core_user\` (\`auth_id\`) on delete cascade);`); + this.addSql(`create index \`discord_role_sync_attempt_auth_id_index\` on \`discord_role_sync_attempt\` (\`auth_id\`);`); + this.addSql(`create index \`discord_role_sync_attempt_discord_user_id_index\` on \`discord_role_sync_attempt\` (\`discord_user_id\`);`); + this.addSql(`create index \`discord_role_sync_attempt_status_index\` on \`discord_role_sync_attempt\` (\`status\`);`); + this.addSql(`create index \`discord_role_sync_attempt_created_at_index\` on \`discord_role_sync_attempt\` (\`created_at\`);`); + + this.addSql(`create table \`discord_member\` (\`auth_id\` text not null primary key, \`discord_user_id\` text not null, \`guild_id\` text not null, \`username\` text not null, \`avatar_hash\` text null, \`verified_at\` datetime not null, \`updated_at\` datetime not null, \`last_role_sync_at\` datetime null, constraint \`discord_member_auth_id_foreign\` foreign key (\`auth_id\`) references \`core_user\` (\`auth_id\`) on update cascade on delete cascade);`); + this.addSql(`create index \`discord_member_auth_id_index\` on \`discord_member\` (\`auth_id\`);`); + this.addSql(`create unique index \`discord_member_discord_user_id_unique\` on \`discord_member\` (\`discord_user_id\`);`); + this.addSql(`create index \`discord_member_discord_user_id_index\` on \`discord_member\` (\`discord_user_id\`);`); + this.addSql(`create index \`discord_member_guild_id_index\` on \`discord_member\` (\`guild_id\`);`); + this.addSql(`create index \`discord_member_updated_at_index\` on \`discord_member\` (\`updated_at\`);`); + + this.addSql(`create table \`core_user_data\` (\`auth_id\` text not null primary key, \`age\` integer not null, \`gender\` text not null, \`race\` text not null, \`ethnicity\` text not null, \`shirt_size\` text not null, \`dietary_restrictions\` json not null default '[]', \`accommodation_note\` text null, \`phone_number\` text null, \`country_of_residence\` text null, \`has_accepted_mlhcode_of_conduct\` integer not null, \`has_shared_data_with_mlh\` integer not null, \`is_emailable\` integer not null, \`completed_at\` datetime not null, \`updated_at\` datetime not null, constraint \`core_user_data_auth_id_foreign\` foreign key (\`auth_id\`) references \`core_user\` (\`auth_id\`) on update cascade on delete cascade);`); + this.addSql(`create index \`core_user_data_auth_id_index\` on \`core_user_data\` (\`auth_id\`);`); + + this.addSql(`create table \`core_user_ban\` (\`auth_id\` text not null primary key, \`reason\` text null, \`banned_by_auth_id\` text not null, \`created_at\` datetime not null, constraint \`core_user_ban_auth_id_foreign\` foreign key (\`auth_id\`) references \`core_user\` (\`auth_id\`) on update cascade on delete cascade, constraint \`core_user_ban_banned_by_auth_id_foreign\` foreign key (\`banned_by_auth_id\`) references \`core_user\` (\`auth_id\`));`); + this.addSql(`create index \`core_user_ban_auth_id_index\` on \`core_user_ban\` (\`auth_id\`);`); + this.addSql(`create index \`core_user_ban_banned_by_auth_id_index\` on \`core_user_ban\` (\`banned_by_auth_id\`);`); + + this.addSql(`create table \`core_setting\` (\`key\` text not null primary key, \`value\` json not null, \`created_at\` datetime not null, \`created_by_auth_id\` text null, \`updated_at\` datetime not null, \`updated_by_auth_id\` text null, constraint \`core_setting_created_by_auth_id_foreign\` foreign key (\`created_by_auth_id\`) references \`core_user\` (\`auth_id\`) on delete set null, constraint \`core_setting_updated_by_auth_id_foreign\` foreign key (\`updated_by_auth_id\`) references \`core_user\` (\`auth_id\`) on delete set null);`); + this.addSql(`create index \`core_setting_created_by_auth_id_index\` on \`core_setting\` (\`created_by_auth_id\`);`); + this.addSql(`create index \`core_setting_updated_at_index\` on \`core_setting\` (\`updated_at\`);`); + this.addSql(`create index \`core_setting_updated_by_auth_id_index\` on \`core_setting\` (\`updated_by_auth_id\`);`); + + this.addSql(`create table \`core_rsvp\` (\`auth_id\` text not null primary key, \`status\` text check (\`status\` in ('confirmed', 'waitlisted', 'cancelled')) not null, \`waitlist_position\` integer null, \`created_at\` datetime not null, \`updated_at\` datetime not null, \`confirmed_at\` datetime null, \`waitlisted_at\` datetime null, \`cancelled_at\` datetime null, \`cancelled_by_auth_id\` text null, \`promoted_at\` datetime null, \`promoted_by_auth_id\` text null, constraint \`core_rsvp_auth_id_foreign\` foreign key (\`auth_id\`) references \`core_user\` (\`auth_id\`) on update cascade on delete cascade, constraint \`core_rsvp_cancelled_by_auth_id_foreign\` foreign key (\`cancelled_by_auth_id\`) references \`core_user\` (\`auth_id\`) on delete set null, constraint \`core_rsvp_promoted_by_auth_id_foreign\` foreign key (\`promoted_by_auth_id\`) references \`core_user\` (\`auth_id\`) on delete set null);`); + this.addSql(`create index \`core_rsvp_auth_id_index\` on \`core_rsvp\` (\`auth_id\`);`); + this.addSql(`create index \`core_rsvp_cancelled_by_auth_id_index\` on \`core_rsvp\` (\`cancelled_by_auth_id\`);`); + this.addSql(`create index \`core_rsvp_promoted_by_auth_id_index\` on \`core_rsvp\` (\`promoted_by_auth_id\`);`); + this.addSql(`create index \`core_rsvp_status_index\` on \`core_rsvp\` (\`status\`);`); + this.addSql(`create index \`core_rsvp_waitlist_position_index\` on \`core_rsvp\` (\`waitlist_position\`);`); + this.addSql(`create index \`core_rsvp_created_at_index\` on \`core_rsvp\` (\`created_at\`);`); + this.addSql(`create index \`core_rsvp_updated_at_index\` on \`core_rsvp\` (\`updated_at\`);`); + + this.addSql(`create table \`core_notification_intent\` (\`id\` text not null primary key, \`kind\` text not null, \`recipient_auth_id\` text null, \`payload\` json not null, \`status\` text check (\`status\` in ('pending', 'processing', 'delivered', 'failed', 'skipped')) not null default 'pending', \`idempotency_key\` text null, \`created_at\` datetime not null, \`updated_at\` datetime not null, constraint \`core_notification_intent_recipient_auth_id_foreign\` foreign key (\`recipient_auth_id\`) references \`core_user\` (\`auth_id\`) on delete set null);`); + this.addSql(`create unique index \`core_notification_intent_idempotency_key_unique\` on \`core_notification_intent\` (\`idempotency_key\`);`); + this.addSql(`create index \`core_notification_intent_kind_index\` on \`core_notification_intent\` (\`kind\`);`); + this.addSql(`create index \`core_notification_intent_recipient_auth_id_index\` on \`core_notification_intent\` (\`recipient_auth_id\`);`); + this.addSql(`create index \`core_notification_intent_status_index\` on \`core_notification_intent\` (\`status\`);`); + this.addSql(`create index \`core_notification_intent_created_at_index\` on \`core_notification_intent\` (\`created_at\`);`); + + this.addSql(`create table \`core_notification_delivery_attempt\` (\`id\` text not null primary key, \`intent_id\` text not null, \`channel\` text not null, \`provider\` text null, \`status\` text check (\`status\` in ('delivered', 'failed', 'skipped')) not null, \`recipient\` text null, \`external_id\` text null, \`error\` text null, \`metadata\` json not null, \`attempted_at\` datetime not null, constraint \`core_notification_delivery_attempt_intent_id_foreign\` foreign key (\`intent_id\`) references \`core_notification_intent\` (\`id\`) on delete cascade);`); + this.addSql(`create index \`core_notification_delivery_attempt_intent_id_index\` on \`core_notification_delivery_attempt\` (\`intent_id\`);`); + this.addSql(`create index \`core_notification_delivery_attempt_channel_index\` on \`core_notification_delivery_attempt\` (\`channel\`);`); + this.addSql(`create index \`core_notification_delivery_attempt_status_index\` on \`core_notification_delivery_attempt\` (\`status\`);`); + this.addSql(`create index \`core_notification_delivery_attempt_attempted_at_index\` on \`core_notification_delivery_attempt\` (\`attempted_at\`);`); + + this.addSql(`create table \`core_hacker\` (\`auth_id\` text not null primary key, \`university\` text not null, \`major\` text not null, \`school_id\` text null, \`level_of_study\` text not null, \`hackathons_attended\` integer not null, \`software_experience\` text not null, \`heard_from\` text null, \`github_url\` text null, \`linked_in_url\` text null, \`personal_website_url\` text null, \`resume_url\` text null, \`group\` text null, \`registered_at\` datetime not null, \`updated_at\` datetime not null, constraint \`core_hacker_auth_id_foreign\` foreign key (\`auth_id\`) references \`core_user\` (\`auth_id\`) on update cascade on delete cascade);`); + this.addSql(`create index \`core_hacker_auth_id_index\` on \`core_hacker\` (\`auth_id\`);`); + this.addSql(`create index \`core_hacker_registered_at_index\` on \`core_hacker\` (\`registered_at\`);`); + + this.addSql(`create table \`core_event_scan\` (\`id\` text not null primary key, \`event_id\` text not null, \`auth_id\` text not null, \`scanned_by_auth_id\` text not null, \`scanned_at\` datetime not null, constraint \`core_event_scan_event_id_foreign\` foreign key (\`event_id\`) references \`core_event\` (\`id\`) on delete cascade, constraint \`core_event_scan_auth_id_foreign\` foreign key (\`auth_id\`) references \`core_user\` (\`auth_id\`) on delete cascade, constraint \`core_event_scan_scanned_by_auth_id_foreign\` foreign key (\`scanned_by_auth_id\`) references \`core_user\` (\`auth_id\`));`); + this.addSql(`create index \`core_event_scan_scanned_by_auth_id_index\` on \`core_event_scan\` (\`scanned_by_auth_id\`);`); + this.addSql(`create index \`core_event_scan_event_id_index\` on \`core_event_scan\` (\`event_id\`);`); + this.addSql(`create index \`core_event_scan_auth_id_index\` on \`core_event_scan\` (\`auth_id\`);`); + this.addSql(`create index \`core_event_scan_event_id_auth_id_index\` on \`core_event_scan\` (\`event_id\`, \`auth_id\`);`); + this.addSql(`create index \`core_event_scan_scanned_at_index\` on \`core_event_scan\` (\`scanned_at\`);`); + + this.addSql(`create table \`teams_team\` (\`id\` text not null primary key, \`name\` text not null, \`tag\` text not null, \`owner_auth_id\` text not null, \`created_at\` datetime not null, constraint \`teams_team_owner_auth_id_foreign\` foreign key (\`owner_auth_id\`) references \`core_user\` (\`auth_id\`) on delete cascade);`); + this.addSql(`create unique index \`teams_team_tag_unique\` on \`teams_team\` (\`tag\`);`); + this.addSql(`create index \`teams_team_tag_index\` on \`teams_team\` (\`tag\`);`); + this.addSql(`create index \`teams_team_owner_auth_id_index\` on \`teams_team\` (\`owner_auth_id\`);`); + + this.addSql(`create table \`teams_member\` (\`id\` text not null primary key, \`team_id\` text not null, \`auth_id\` text not null, \`joined_at\` datetime not null, constraint \`teams_member_team_id_foreign\` foreign key (\`team_id\`) references \`teams_team\` (\`id\`) on delete cascade, constraint \`teams_member_auth_id_foreign\` foreign key (\`auth_id\`) references \`core_user\` (\`auth_id\`) on delete cascade);`); + this.addSql(`create unique index \`teams_member_auth_id_unique\` on \`teams_member\` (\`auth_id\`);`); + this.addSql(`create index \`teams_member_team_id_index\` on \`teams_member\` (\`team_id\`);`); + this.addSql(`create index \`teams_member_auth_id_index\` on \`teams_member\` (\`auth_id\`);`); + + this.addSql(`create table \`teams_invite\` (\`id\` text not null primary key, \`team_id\` text not null, \`invitee_auth_id\` text not null, \`status\` text check (\`status\` in ('pending', 'accepted', 'declined')) not null default 'pending', \`created_at\` datetime not null, constraint \`teams_invite_team_id_foreign\` foreign key (\`team_id\`) references \`teams_team\` (\`id\`) on delete cascade, constraint \`teams_invite_invitee_auth_id_foreign\` foreign key (\`invitee_auth_id\`) references \`core_user\` (\`auth_id\`) on delete cascade);`); + this.addSql(`create index \`teams_invite_team_id_index\` on \`teams_invite\` (\`team_id\`);`); + this.addSql(`create index \`teams_invite_invitee_auth_id_index\` on \`teams_invite\` (\`invitee_auth_id\`);`); + this.addSql(`create index \`teams_invite_team_id_invitee_auth_id_index\` on \`teams_invite\` (\`team_id\`, \`invitee_auth_id\`);`); + } + + override down(): void | Promise { + + this.addSql(`drop table if exists \`user\`;`); + this.addSql(`drop table if exists \`session\`;`); + this.addSql(`drop table if exists \`account\`;`); + this.addSql(`drop table if exists \`verification\`;`); + this.addSql(`drop table if exists \`core_event\`;`); + this.addSql(`drop table if exists \`core_operation_lock\`;`); + this.addSql(`drop table if exists \`core_role\`;`); + this.addSql(`drop table if exists \`core_user\`;`); + this.addSql(`drop table if exists \`discord_verification\`;`); + this.addSql(`drop table if exists \`discord_role_sync_attempt\`;`); + this.addSql(`drop table if exists \`discord_member\`;`); + this.addSql(`drop table if exists \`core_user_data\`;`); + this.addSql(`drop table if exists \`core_user_ban\`;`); + this.addSql(`drop table if exists \`core_setting\`;`); + this.addSql(`drop table if exists \`core_rsvp\`;`); + this.addSql(`drop table if exists \`core_notification_intent\`;`); + this.addSql(`drop table if exists \`core_notification_delivery_attempt\`;`); + this.addSql(`drop table if exists \`core_hacker\`;`); + this.addSql(`drop table if exists \`core_event_scan\`;`); + this.addSql(`drop table if exists \`teams_team\`;`); + this.addSql(`drop table if exists \`teams_member\`;`); + this.addSql(`drop table if exists \`teams_invite\`;`); + } + +} diff --git a/apps/web/db/schema/auth.ts b/apps/web/db/schema/auth.ts deleted file mode 100644 index 66fc6334..00000000 --- a/apps/web/db/schema/auth.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { relations, sql } from "drizzle-orm"; -import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core"; - -export const user = sqliteTable("user", { - id: text("id").primaryKey(), - name: text("name").notNull(), - email: text("email").notNull().unique(), - emailVerified: integer("email_verified", { mode: "boolean" }) - .default(false) - .notNull(), - image: text("image"), - createdAt: integer("created_at", { mode: "timestamp_ms" }) - .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) - .notNull(), - updatedAt: integer("updated_at", { mode: "timestamp_ms" }) - .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) - .$onUpdate(() => /* @__PURE__ */ new Date()) - .notNull(), -}); - -export const session = sqliteTable( - "session", - { - id: text("id").primaryKey(), - expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), - token: text("token").notNull().unique(), - createdAt: integer("created_at", { mode: "timestamp_ms" }) - .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) - .notNull(), - updatedAt: integer("updated_at", { mode: "timestamp_ms" }) - .$onUpdate(() => /* @__PURE__ */ new Date()) - .notNull(), - ipAddress: text("ip_address"), - userAgent: text("user_agent"), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - }, - (table) => [index("session_userId_idx").on(table.userId)], -); - -export const account = sqliteTable( - "account", - { - id: text("id").primaryKey(), - accountId: text("account_id").notNull(), - providerId: text("provider_id").notNull(), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - accessToken: text("access_token"), - refreshToken: text("refresh_token"), - idToken: text("id_token"), - accessTokenExpiresAt: integer("access_token_expires_at", { - mode: "timestamp_ms", - }), - refreshTokenExpiresAt: integer("refresh_token_expires_at", { - mode: "timestamp_ms", - }), - scope: text("scope"), - password: text("password"), - createdAt: integer("created_at", { mode: "timestamp_ms" }) - .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) - .notNull(), - updatedAt: integer("updated_at", { mode: "timestamp_ms" }) - .$onUpdate(() => /* @__PURE__ */ new Date()) - .notNull(), - }, - (table) => [index("account_userId_idx").on(table.userId)], -); - -export const verification = sqliteTable( - "verification", - { - id: text("id").primaryKey(), - identifier: text("identifier").notNull(), - value: text("value").notNull(), - expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), - createdAt: integer("created_at", { mode: "timestamp_ms" }) - .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) - .notNull(), - updatedAt: integer("updated_at", { mode: "timestamp_ms" }) - .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) - .$onUpdate(() => /* @__PURE__ */ new Date()) - .notNull(), - }, - (table) => [index("verification_identifier_idx").on(table.identifier)], -); - -export const userRelations = relations(user, ({ many }) => ({ - sessions: many(session), - accounts: many(account), -})); - -export const sessionRelations = relations(session, ({ one }) => ({ - user: one(user, { - fields: [session.userId], - references: [user.id], - }), -})); - -export const accountRelations = relations(account, ({ one }) => ({ - user: one(user, { - fields: [account.userId], - references: [user.id], - }), -})); diff --git a/apps/web/db/schema/hackkit.ts b/apps/web/db/schema/hackkit.ts deleted file mode 100644 index 2bfac16b..00000000 --- a/apps/web/db/schema/hackkit.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { index, integer, real, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; - -export const coreEvent = sqliteTable("core_event", { - id: text("id").primaryKey().notNull(), - title: text("title").notNull(), - startTime: integer("startTime", { mode: "timestamp" }).notNull(), - endTime: integer("endTime", { mode: "timestamp" }).notNull(), - location: text("location").notNull().default("TBD"), - description: text("description").notNull(), - type: text("type").notNull(), - host: text("host"), - hidden: integer("hidden", { mode: "boolean" }).notNull().default(false), - createdAt: integer("createdAt", { mode: "timestamp" }).notNull(), - updatedAt: integer("updatedAt", { mode: "timestamp" }).notNull() -}, (table) => ({ - core_event_startTime_idx: index("core_event_startTime_idx").on(table.startTime), - core_event_type_idx: index("core_event_type_idx").on(table.type), - core_event_hidden_idx: index("core_event_hidden_idx").on(table.hidden) -})); - -export const coreRole = sqliteTable("core_role", { - id: text("id").primaryKey().notNull(), - name: text("name").notNull().unique(), - position: integer("position").notNull(), - permissions: text("permissions", { mode: "json" }).notNull().default([]), - color: text("color"), - createdAt: integer("createdAt", { mode: "timestamp" }).notNull(), - updatedAt: integer("updatedAt", { mode: "timestamp" }).notNull() -}, (table) => ({ - core_role_position_idx: index("core_role_position_idx").on(table.position) -})); - -export const coreUser = sqliteTable("core_user", { - authId: text("authId").primaryKey().notNull(), - email: text("email").notNull().unique(), - firstName: text("firstName").notNull(), - lastName: text("lastName").notNull(), - profilePhotoUrl: text("profilePhotoUrl"), - hackTag: text("hackTag").unique(), - bio: text("bio"), - pronouns: text("pronouns"), - skills: text("skills", { mode: "json" }).notNull().default([]), - isProfileSearchable: integer("isProfileSearchable", { mode: "boolean" }).notNull().default(true), - discordDisplayHandle: text("discordDisplayHandle"), - roleId: text("roleId").references(() => coreRole.id, { onDelete: "set null" }), - isApproved: integer("isApproved", { mode: "boolean" }).notNull().default(false), - checkedInAt: integer("checkedInAt", { mode: "timestamp" }), - createdAt: integer("createdAt", { mode: "timestamp" }).notNull(), - updatedAt: integer("updatedAt", { mode: "timestamp" }).notNull() -}, (table) => ({ - core_user_email_idx: index("core_user_email_idx").on(table.email), - core_user_hackTag_idx: index("core_user_hackTag_idx").on(table.hackTag), - core_user_roleId_idx: index("core_user_roleId_idx").on(table.roleId), - core_user_createdAt_idx: index("core_user_createdAt_idx").on(table.createdAt) -})); - -export const coreEventScan = sqliteTable("core_eventScan", { - id: text("id").primaryKey().notNull(), - eventId: text("eventId").notNull().references(() => coreEvent.id, { onDelete: "cascade" }), - authId: text("authId").notNull().references(() => coreUser.authId, { onDelete: "cascade" }), - scannedByAuthId: text("scannedByAuthId").notNull().references(() => coreUser.authId), - scannedAt: integer("scannedAt", { mode: "timestamp" }).notNull() -}, (table) => ({ - core_eventScan_eventId_idx: index("core_eventScan_eventId_idx").on(table.eventId), - core_eventScan_authId_idx: index("core_eventScan_authId_idx").on(table.authId), - core_eventScan_eventId_authId_idx: index("core_eventScan_eventId_authId_idx").on(table.eventId, table.authId), - core_eventScan_scannedAt_idx: index("core_eventScan_scannedAt_idx").on(table.scannedAt) -})); - -export const coreHacker = sqliteTable("core_hacker", { - authId: text("authId").primaryKey().notNull().references(() => coreUser.authId, { onDelete: "cascade" }), - university: text("university").notNull(), - major: text("major").notNull(), - schoolId: text("schoolId"), - levelOfStudy: text("levelOfStudy").notNull(), - hackathonsAttended: integer("hackathonsAttended").notNull(), - softwareExperience: text("softwareExperience").notNull(), - heardFrom: text("heardFrom"), - githubUrl: text("githubUrl"), - linkedInUrl: text("linkedInUrl"), - personalWebsiteUrl: text("personalWebsiteUrl"), - resumeUrl: text("resumeUrl"), - group: text("group"), - registeredAt: integer("registeredAt", { mode: "timestamp" }).notNull(), - updatedAt: integer("updatedAt", { mode: "timestamp" }).notNull() -}, (table) => ({ - core_hacker_registeredAt_idx: index("core_hacker_registeredAt_idx").on(table.registeredAt) -})); - -export const coreNotificationIntent = sqliteTable("core_notificationIntent", { - id: text("id").primaryKey().notNull(), - kind: text("kind").notNull(), - recipientAuthId: text("recipientAuthId").references(() => coreUser.authId, { onDelete: "set null" }), - payload: text("payload", { mode: "json" }).notNull().default({}), - status: text("status").notNull().default("pending"), - idempotencyKey: text("idempotencyKey").unique(), - createdAt: integer("createdAt", { mode: "timestamp" }).notNull(), - updatedAt: integer("updatedAt", { mode: "timestamp" }).notNull() -}, (table) => ({ - core_notificationIntent_kind_idx: index("core_notificationIntent_kind_idx").on(table.kind), - core_notificationIntent_recipientAuthId_idx: index("core_notificationIntent_recipientAuthId_idx").on(table.recipientAuthId), - core_notificationIntent_status_idx: index("core_notificationIntent_status_idx").on(table.status), - core_notificationIntent_createdAt_idx: index("core_notificationIntent_createdAt_idx").on(table.createdAt) -})); - -export const coreNotificationDeliveryAttempt = sqliteTable("core_notificationDeliveryAttempt", { - id: text("id").primaryKey().notNull(), - intentId: text("intentId").notNull().references(() => coreNotificationIntent.id, { onDelete: "cascade" }), - channel: text("channel").notNull(), - provider: text("provider"), - status: text("status").notNull(), - recipient: text("recipient"), - externalId: text("externalId"), - error: text("error"), - metadata: text("metadata", { mode: "json" }).notNull().default({}), - attemptedAt: integer("attemptedAt", { mode: "timestamp" }).notNull() -}, (table) => ({ - core_notificationDeliveryAttempt_intentId_idx: index("core_notificationDeliveryAttempt_intentId_idx").on(table.intentId), - core_notificationDeliveryAttempt_channel_idx: index("core_notificationDeliveryAttempt_channel_idx").on(table.channel), - core_notificationDeliveryAttempt_status_idx: index("core_notificationDeliveryAttempt_status_idx").on(table.status), - core_notificationDeliveryAttempt_attemptedAt_idx: index("core_notificationDeliveryAttempt_attemptedAt_idx").on(table.attemptedAt) -})); - -export const coreRsvp = sqliteTable("core_rsvp", { - authId: text("authId").primaryKey().notNull().references(() => coreUser.authId, { onDelete: "cascade" }), - status: text("status").notNull(), - waitlistPosition: integer("waitlistPosition"), - createdAt: integer("createdAt", { mode: "timestamp" }).notNull(), - updatedAt: integer("updatedAt", { mode: "timestamp" }).notNull(), - confirmedAt: integer("confirmedAt", { mode: "timestamp" }), - waitlistedAt: integer("waitlistedAt", { mode: "timestamp" }), - cancelledAt: integer("cancelledAt", { mode: "timestamp" }), - cancelledByAuthId: text("cancelledByAuthId").references(() => coreUser.authId, { onDelete: "set null" }), - promotedAt: integer("promotedAt", { mode: "timestamp" }), - promotedByAuthId: text("promotedByAuthId").references(() => coreUser.authId, { onDelete: "set null" }) -}, (table) => ({ - core_rsvp_status_idx: index("core_rsvp_status_idx").on(table.status), - core_rsvp_waitlistPosition_idx: index("core_rsvp_waitlistPosition_idx").on(table.waitlistPosition), - core_rsvp_createdAt_idx: index("core_rsvp_createdAt_idx").on(table.createdAt), - core_rsvp_updatedAt_idx: index("core_rsvp_updatedAt_idx").on(table.updatedAt) -})); - -export const coreSetting = sqliteTable("core_setting", { - key: text("key").primaryKey().notNull(), - value: text("value", { mode: "json" }).notNull(), - createdAt: integer("createdAt", { mode: "timestamp" }).notNull(), - createdByAuthId: text("createdByAuthId").references(() => coreUser.authId, { onDelete: "set null" }), - updatedAt: integer("updatedAt", { mode: "timestamp" }).notNull(), - updatedByAuthId: text("updatedByAuthId").references(() => coreUser.authId, { onDelete: "set null" }) -}, (table) => ({ - core_setting_updatedAt_idx: index("core_setting_updatedAt_idx").on(table.updatedAt), - core_setting_updatedByAuthId_idx: index("core_setting_updatedByAuthId_idx").on(table.updatedByAuthId) -})); - -export const coreUserBan = sqliteTable("core_userBan", { - authId: text("authId").primaryKey().notNull().references(() => coreUser.authId, { onDelete: "cascade" }), - reason: text("reason"), - bannedByAuthId: text("bannedByAuthId").notNull().references(() => coreUser.authId), - createdAt: integer("createdAt", { mode: "timestamp" }).notNull() -}); - -export const coreUserData = sqliteTable("core_userData", { - authId: text("authId").primaryKey().notNull().references(() => coreUser.authId, { onDelete: "cascade" }), - age: integer("age").notNull(), - gender: text("gender").notNull(), - race: text("race").notNull(), - ethnicity: text("ethnicity").notNull(), - shirtSize: text("shirtSize").notNull(), - dietaryRestrictions: text("dietaryRestrictions", { mode: "json" }).notNull().default([]), - accommodationNote: text("accommodationNote"), - phoneNumber: text("phoneNumber"), - countryOfResidence: text("countryOfResidence"), - hasAcceptedMLHCodeOfConduct: integer("hasAcceptedMLHCodeOfConduct", { mode: "boolean" }).notNull(), - hasSharedDataWithMLH: integer("hasSharedDataWithMLH", { mode: "boolean" }).notNull(), - isEmailable: integer("isEmailable", { mode: "boolean" }).notNull(), - completedAt: integer("completedAt", { mode: "timestamp" }).notNull(), - updatedAt: integer("updatedAt", { mode: "timestamp" }).notNull() -}); - -export const discordMember = sqliteTable("discord_member", { - authId: text("authId").primaryKey().notNull().references(() => coreUser.authId, { onDelete: "cascade" }), - discordUserId: text("discordUserId").notNull().unique(), - guildId: text("guildId").notNull(), - username: text("username").notNull(), - avatarHash: text("avatarHash"), - verifiedAt: integer("verifiedAt", { mode: "timestamp" }).notNull(), - updatedAt: integer("updatedAt", { mode: "timestamp" }).notNull(), - lastRoleSyncAt: integer("lastRoleSyncAt", { mode: "timestamp" }) -}, (table) => ({ - discord_member_discordUserId_idx: index("discord_member_discordUserId_idx").on(table.discordUserId), - discord_member_guildId_idx: index("discord_member_guildId_idx").on(table.guildId), - discord_member_updatedAt_idx: index("discord_member_updatedAt_idx").on(table.updatedAt) -})); - -export const discordRoleSyncAttempt = sqliteTable("discord_roleSyncAttempt", { - id: text("id").primaryKey().notNull(), - authId: text("authId").notNull().references(() => coreUser.authId, { onDelete: "cascade" }), - discordUserId: text("discordUserId").notNull(), - guildId: text("guildId").notNull(), - status: text("status").notNull(), - roleIds: text("roleIds", { mode: "json" }).notNull().default([]), - roleNames: text("roleNames", { mode: "json" }).notNull().default([]), - error: text("error"), - createdAt: integer("createdAt", { mode: "timestamp" }).notNull() -}, (table) => ({ - discord_roleSyncAttempt_authId_idx: index("discord_roleSyncAttempt_authId_idx").on(table.authId), - discord_roleSyncAttempt_discordUserId_idx: index("discord_roleSyncAttempt_discordUserId_idx").on(table.discordUserId), - discord_roleSyncAttempt_status_idx: index("discord_roleSyncAttempt_status_idx").on(table.status), - discord_roleSyncAttempt_createdAt_idx: index("discord_roleSyncAttempt_createdAt_idx").on(table.createdAt) -})); - -export const discordVerification = sqliteTable("discord_verification", { - code: text("code").primaryKey().notNull(), - discordUserId: text("discordUserId").notNull(), - guildId: text("guildId").notNull(), - username: text("username").notNull(), - avatarHash: text("avatarHash"), - authId: text("authId").references(() => coreUser.authId, { onDelete: "set null" }), - status: text("status").notNull().default("pending"), - expiresAt: integer("expiresAt", { mode: "timestamp" }).notNull(), - createdAt: integer("createdAt", { mode: "timestamp" }).notNull(), - acceptedAt: integer("acceptedAt", { mode: "timestamp" }) -}, (table) => ({ - discord_verification_discordUserId_idx: index("discord_verification_discordUserId_idx").on(table.discordUserId), - discord_verification_guildId_idx: index("discord_verification_guildId_idx").on(table.guildId), - discord_verification_authId_idx: index("discord_verification_authId_idx").on(table.authId), - discord_verification_status_idx: index("discord_verification_status_idx").on(table.status), - discord_verification_createdAt_idx: index("discord_verification_createdAt_idx").on(table.createdAt) -})); - -export const teamsTeam = sqliteTable("teams_team", { - id: text("id").primaryKey().notNull(), - name: text("name").notNull(), - tag: text("tag").notNull().unique(), - ownerAuthId: text("ownerAuthId").notNull().references(() => coreUser.authId, { onDelete: "cascade" }), - createdAt: integer("createdAt", { mode: "timestamp" }).notNull() -}, (table) => ({ - teams_team_tag_idx: index("teams_team_tag_idx").on(table.tag), - teams_team_ownerAuthId_idx: index("teams_team_ownerAuthId_idx").on(table.ownerAuthId) -})); - -export const teamsInvite = sqliteTable("teams_invite", { - id: text("id").primaryKey().notNull(), - teamId: text("teamId").notNull().references(() => teamsTeam.id, { onDelete: "cascade" }), - inviteeAuthId: text("inviteeAuthId").notNull().references(() => coreUser.authId, { onDelete: "cascade" }), - status: text("status").notNull().default("pending"), - createdAt: integer("createdAt", { mode: "timestamp" }).notNull() -}, (table) => ({ - teams_invite_teamId_idx: index("teams_invite_teamId_idx").on(table.teamId), - teams_invite_inviteeAuthId_idx: index("teams_invite_inviteeAuthId_idx").on(table.inviteeAuthId), - teams_invite_teamId_inviteeAuthId_idx: index("teams_invite_teamId_inviteeAuthId_idx").on(table.teamId, table.inviteeAuthId) -})); - -export const teamsMember = sqliteTable("teams_member", { - id: text("id").primaryKey().notNull(), - teamId: text("teamId").notNull().references(() => teamsTeam.id, { onDelete: "cascade" }), - authId: text("authId").notNull().unique().references(() => coreUser.authId, { onDelete: "cascade" }), - joinedAt: integer("joinedAt", { mode: "timestamp" }).notNull() -}, (table) => ({ - teams_member_teamId_idx: index("teams_member_teamId_idx").on(table.teamId), - teams_member_authId_idx: index("teams_member_authId_idx").on(table.authId) -})); diff --git a/apps/web/drizzle.config.ts b/apps/web/drizzle.config.ts deleted file mode 100644 index 527ae2e8..00000000 --- a/apps/web/drizzle.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { defineConfig } from "drizzle-kit"; -import { env } from "./env"; - -export default defineConfig({ - schema: "./db/schema/**/*.ts", - out: "./db/migrations", - dialect: "turso", - dbCredentials: { - url: env.databaseUrl, - authToken: env.tursoAuthToken, - }, -}); diff --git a/apps/web/env.ts b/apps/web/env.ts index 0a830898..d31148b4 100644 --- a/apps/web/env.ts +++ b/apps/web/env.ts @@ -1,3 +1,5 @@ +type DatabaseDialect = "sqlite" | "libsql" | "mysql" | "postgresql"; + type BlobAdapter = "local" | "s3"; type EmailProvider = "none" | "resend" | "smtp"; @@ -8,6 +10,7 @@ type TestWebEnv = { betterAuthSecret: string; betterAuthTrustedOrigins: string[]; databaseUrl: string; + databaseDialect: DatabaseDialect; tursoAuthToken?: string; blobAdapter: BlobAdapter; localBlobBaseDir: string; @@ -105,16 +108,6 @@ function assertProductionEnv(env: TestWebEnv): void { ); } - if (env.databaseUrl.startsWith("file:")) { - throw new Error( - "DATABASE_URL must point to remote libSQL/Turso in production.", - ); - } - - if (!env.tursoAuthToken) { - throw new Error("TURSO_AUTH_TOKEN must be set in production."); - } - if (env.blobAdapter !== "s3") { throw new Error("HACKKIT_BLOB_ADAPTER must be s3 in production."); } @@ -156,6 +149,21 @@ function assertProductionEnv(env: TestWebEnv): void { } } +function resolveDatabaseDialect(url: string): DatabaseDialect { + const dialect = read("HACKKIT_DATABASE_DIALECT"); + if (dialect) { + if (["sqlite", "libsql", "mysql", "postgresql"].includes(dialect)) + return dialect as DatabaseDialect; + throw new Error( + "HACKKIT_DATABASE_DIALECT must be sqlite, libsql, mysql, or postgresql.", + ); + } + if (/^postgres(ql)?:/.test(url)) return "postgresql"; + if (url.startsWith("mysql:")) return "mysql"; + if (/^(libsql|https?):/.test(url)) return "libsql"; + return "sqlite"; +} + export function resolveTestWebEnv(): TestWebEnv { const nodeEnv = resolveNodeEnv(); const appUrl = @@ -166,6 +174,7 @@ export function resolveTestWebEnv(): TestWebEnv { const blobAdapter = resolveBlobAdapter(nodeEnv); const emailProvider = resolveEmailProvider(); + const databaseUrl = read("DATABASE_URL") ?? "file:.data/web.db"; const env: TestWebEnv = { nodeEnv, appUrl, @@ -174,7 +183,8 @@ export function resolveTestWebEnv(): TestWebEnv { read("BETTER_AUTH_SECRET") ?? "web-development-secret-change-me-please", betterAuthTrustedOrigins: readList("BETTER_AUTH_TRUSTED_ORIGINS"), - databaseUrl: read("DATABASE_URL") ?? "file:.data/web.db", + databaseUrl, + databaseDialect: resolveDatabaseDialect(databaseUrl), tursoAuthToken: read("TURSO_AUTH_TOKEN"), blobAdapter, localBlobBaseDir: diff --git a/apps/web/hackkit.config.ts b/apps/web/hackkit.config.ts index 12396061..49adca3f 100644 --- a/apps/web/hackkit.config.ts +++ b/apps/web/hackkit.config.ts @@ -1,6 +1,7 @@ import { defineHackkitConfig } from "@hackkit/config"; import { CorePermission, type PermissionKey } from "@hackkit/core"; -import { createDrizzleLibsqlAdapter } from "@hackkit/db-drizzle/libsql"; +import { databaseOptions } from "./lib/database-config"; +import { authOptions } from "./lib/auth-options"; import { createResendEmailProvider, createSmtpEmailProvider, @@ -13,7 +14,6 @@ import { } from "@hackkit/plugin-discord"; import { teamsPlugin, TeamsPermission } from "@hackkit/plugin-teams"; import { env } from "./env"; -import { getDb } from "./lib/db"; const participantPermissions = [] as PermissionKey[]; @@ -140,7 +140,8 @@ function createDiscordRoleSyncProvider() { } export default defineHackkitConfig({ - database: createDrizzleLibsqlAdapter(getDb), + database: databaseOptions, + auth: authOptions, defaultCompetitorRoleId: "core.participant", groups, seedRoles: [ diff --git a/apps/web/lib/auth-options.ts b/apps/web/lib/auth-options.ts new file mode 100644 index 00000000..401ae1bd --- /dev/null +++ b/apps/web/lib/auth-options.ts @@ -0,0 +1,29 @@ +import type { HackKitAuthOptions } from "@hackkit/core"; +import { env } from "../env"; + +const socialProviders = { + ...(env.githubClientId && env.githubClientSecret + ? { + github: { + clientId: env.githubClientId, + clientSecret: env.githubClientSecret, + }, + } + : {}), + ...(env.googleClientId && env.googleClientSecret + ? { + google: { + clientId: env.googleClientId, + clientSecret: env.googleClientSecret, + }, + } + : {}), +}; + +export const authOptions = { + baseURL: env.betterAuthUrl, + secret: env.betterAuthSecret, + trustedOrigins: env.betterAuthTrustedOrigins, + emailAndPassword: { enabled: true }, + socialProviders, +} satisfies HackKitAuthOptions; diff --git a/apps/web/lib/auth.ts b/apps/web/lib/auth.ts index 53fd18a7..66b0565a 100644 --- a/apps/web/lib/auth.ts +++ b/apps/web/lib/auth.ts @@ -1,51 +1,11 @@ import "server-only"; +import { headers } from "next/headers"; +import { getCoreRuntime } from "./runtime"; -import { betterAuth } from "better-auth"; -import { drizzleAdapter } from "better-auth/adapters/drizzle"; -import { nextCookies } from "better-auth/next-js"; -import { toBetterAuthLogger } from "@hackkit/auth-better-auth"; -import { getAuthSession as getAuthSessionFromAuth } from "@hackkit/auth-better-auth/session"; -import { getDb } from "./db"; -import { getAppLogger } from "./logger"; -import { env } from "../env"; -import { account, session, user, verification } from "../db/schema/auth"; - -const socialProviders = { - ...(env.githubClientId && env.githubClientSecret - ? { - github: { - clientId: env.githubClientId, - clientSecret: env.githubClientSecret, - }, - } - : {}), - ...(env.googleClientId && env.googleClientSecret - ? { - google: { - clientId: env.googleClientId, - clientSecret: env.googleClientSecret, - }, - } - : {}), -}; - -export const auth = betterAuth({ - baseURL: env.betterAuthUrl, - secret: env.betterAuthSecret, - trustedOrigins: env.betterAuthTrustedOrigins, - database: drizzleAdapter(getDb(), { - provider: "sqlite", - schema: { user, session, account, verification }, - camelCase: true, - }), - emailAndPassword: { - enabled: true, - }, - socialProviders, - plugins: [nextCookies()], - logger: toBetterAuthLogger(getAppLogger()), -}); +export async function getAuth() { + return (await getCoreRuntime()).auth; +} export async function getAuthSession() { - return getAuthSessionFromAuth(auth); + return (await getAuth()).api.getSession({ headers: await headers() }); } diff --git a/apps/web/lib/database-config.ts b/apps/web/lib/database-config.ts new file mode 100644 index 00000000..cdb6eb50 --- /dev/null +++ b/apps/web/lib/database-config.ts @@ -0,0 +1,59 @@ +import { resolve } from "node:path"; +import type { HackKitDatabaseOptions } from "@hackkit/core"; +import { SqliteDriver } from "@mikro-orm/sqlite"; +import { LibSqlDriver } from "@mikro-orm/libsql"; +import { MySqlDriver } from "@mikro-orm/mysql"; +import { PostgreSqlDriver } from "@mikro-orm/postgresql"; +import { env } from "../env"; + +// Next and the HackKit CLI run from the application directory. +const appDirectory = process.cwd(); +const migrations = { + path: resolve( + appDirectory, + "db/migrations", + env.databaseDialect === "libsql" ? "sqlite" : env.databaseDialect, + ), + emit: "ts" as const, + snapshot: true, + snapshotName: ".snapshot", + snapshotOnMigrate: false, +}; + +function resolveDatabaseOptions(): HackKitDatabaseOptions { + switch (env.databaseDialect) { + case "sqlite": + return { + driver: SqliteDriver, + dbName: + env.databaseUrl === ":memory:" + ? ":memory:" + : resolve( + appDirectory, + env.databaseUrl.replace(/^file:/, ""), + ), + migrations, + }; + case "libsql": + return { + driver: LibSqlDriver, + dbName: env.databaseUrl, + password: env.tursoAuthToken, + migrations, + }; + case "mysql": + return { + driver: MySqlDriver, + clientUrl: env.databaseUrl, + migrations, + }; + case "postgresql": + return { + driver: PostgreSqlDriver, + clientUrl: env.databaseUrl, + migrations, + }; + } +} + +export const databaseOptions = resolveDatabaseOptions(); diff --git a/apps/web/lib/db.ts b/apps/web/lib/db.ts deleted file mode 100644 index b752fa0d..00000000 --- a/apps/web/lib/db.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createClient } from "@libsql/client"; -import { drizzle } from "drizzle-orm/libsql"; -import { env } from "../env"; - -let database: ReturnType | undefined; - -export function getDb() { - if (database) return database; - const client = createClient({ - url: env.databaseUrl, - authToken: env.tursoAuthToken, - }); - database = drizzle(client); - return database; -} diff --git a/apps/web/lib/runtime.ts b/apps/web/lib/runtime.ts index cd9d12c8..7391a512 100644 --- a/apps/web/lib/runtime.ts +++ b/apps/web/lib/runtime.ts @@ -1,23 +1,19 @@ import "server-only"; -import { betterAuthAdapter } from "@hackkit/auth-better-auth"; import { createHackkitRuntimeFromConfig, setHackkitRuntime, } from "@hackkit/next"; -import { auth } from "./auth"; -import { getAppLogger } from "./logger"; import { appConfig } from "./app-config"; -const runtimePromise = createHackkitRuntimeFromConfig({ +const host = createHackkitRuntimeFromConfig({ config: appConfig, - auth: betterAuthAdapter({ auth, logger: getAppLogger() }), }); -setHackkitRuntime(runtimePromise); +setHackkitRuntime(host.getRuntime); export async function getRuntime() { - return runtimePromise; + return host.getRuntime(); } export async function getCurrentUser() { @@ -31,3 +27,7 @@ export async function getHackkit() { export async function getPageGuards() { return (await getRuntime()).pageGuards; } + +export function getCoreRuntime() { + return host.core; +} diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 11cbdcd1..62740dc9 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -1,10 +1,22 @@ /** @type {import('next').NextConfig} */ const nextConfig = { + experimental: { + // SQL drivers load native modules and must execute through Node. + serverComponentsExternalPackages: [ + "@hackkit/core", + "@mikro-orm/core", + "@mikro-orm/sql", + "@mikro-orm/sqlite", + "@mikro-orm/libsql", + "@mikro-orm/mysql", + "@mikro-orm/postgresql", + "@mikro-orm/migrations", + "@a77ay/better-auth-mikro-orm", + ], + }, transpilePackages: [ - "@hackkit/core", "@hackkit/ui", "@hackkit/next", - "@hackkit/auth-better-auth", "@hackkit/plugin-teams", "@hackkit/plugin-discord", ], diff --git a/apps/web/package.json b/apps/web/package.json index 5e07e82d..5eea4bc1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,10 +5,8 @@ "type": "module", "scripts": { "plugin:sync": "hackkit plugin sync", - "auth:schema": "auth generate --config better-auth.config.ts --output db/schema/auth.ts --yes", - "schema:generate": "hackkit db schema generate --output db/schema/hackkit.ts", - "db:generate": "drizzle-kit generate", - "db:migrate": "drizzle-kit migrate", + "db:generate": "hackkit db generate", + "db:migrate": "hackkit db migrate", "db:seed": "hackkit db seed", "db:reset": "bash scripts/db-reset.sh", "test": "vitest run db/__tests__/database-workflow.test.ts", @@ -19,39 +17,38 @@ "build": "next build", "verify": "pnpm sync && pnpm typecheck && pnpm build", "start": "next start", - "db:studio": "drizzle-kit studio", - "studio": "pnpm db:studio", - "typecheck": "turbo run build --filter=@hackkit/cli && tsc -p tsconfig.json --noEmit" + "typecheck": "turbo run build --filter=@hackkit/cli && tsc -p tsconfig.json --noEmit", + "test:db-matrix": "vitest run db/__tests__/dialects.test.ts", + "test:runtime": "node scripts/test-runtime.mjs" }, "dependencies": { - "@hackkit/auth-better-auth": "workspace:*", "@hackkit/blob-local": "workspace:*", "@hackkit/blob-s3": "workspace:*", "@hackkit/cli": "workspace:*", "@hackkit/config": "workspace:*", "@hackkit/core": "workspace:*", - "@hackkit/db-drizzle": "workspace:*", "@hackkit/next": "workspace:*", "@hackkit/plugin-discord": "workspace:*", "@hackkit/plugin-notifications-email": "workspace:*", "@hackkit/plugin-teams": "workspace:*", "@hackkit/ui": "workspace:*", - "@libsql/client": "^0.14.0", - "better-auth": "1.6.11", - "drizzle-orm": "^0.45.2", + "better-auth": "1.7.6", "next": "14.2.35", "react": "18.3.1", "react-dom": "18.3.1", "sonner": "^1.5.0", - "typescript": "5.5.3" + "typescript": "5.9.3", + "@mikro-orm/sqlite": "7.2.1", + "@mikro-orm/libsql": "7.2.1", + "@mikro-orm/mysql": "7.2.1", + "@mikro-orm/postgresql": "7.2.1", + "@mikro-orm/migrations": "7.2.1" }, "devDependencies": { - "@types/node": "20.14.11", + "@types/node": "22.18.6", "@types/react": "18.3.3", "@types/react-dom": "18.3.0", "autoprefixer": "10.4.19", - "auth": "1.6.11", - "drizzle-kit": "^0.31.4", "postcss": "8.4.39", "tailwindcss": "3.4.6", "tailwindcss-animate": "1.0.7", diff --git a/apps/web/scripts/test-db-workflow.sh b/apps/web/scripts/test-db-workflow.sh index b5d4a243..bb4f79ad 100644 --- a/apps/web/scripts/test-db-workflow.sh +++ b/apps/web/scripts/test-db-workflow.sh @@ -2,22 +2,14 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -TEST_DIR="$ROOT/.data/db-workflow-test" - -rm -rf "$TEST_DIR" -mkdir -p "$TEST_DIR" +TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/hackkit-db-workflow.XXXXXX")" +export HACKKIT_DATABASE_DIALECT=sqlite trap 'rm -rf "$TEST_DIR"' EXIT cd "$ROOT" -auth_before="$(git hash-object db/schema/auth.ts)" -hackkit_before="$(git hash-object db/schema/hackkit.ts)" - DATABASE_URL="file:$TEST_DIR/sync-must-not-connect.db" pnpm sync - test ! -e "$TEST_DIR/sync-must-not-connect.db" -test "$auth_before" = "$(git hash-object db/schema/auth.ts)" -test "$hackkit_before" = "$(git hash-object db/schema/hackkit.ts)" if DATABASE_URL="libsql://remote.example.com" pnpm db:reset >/dev/null 2>&1; then echo "db:reset accepted a remote URL" >&2 @@ -31,22 +23,13 @@ test -f "$TEST_DIR/selected.db" test "$(cat "$TEST_DIR/untouched.db")" = "leave me alone" node --input-type=module -e ' - import { createClient } from "@libsql/client"; - const client = createClient({ url: process.argv[1] }); - const result = await client.execute({ - sql: "SELECT name FROM sqlite_master WHERE type = ? AND name IN (?, ?)", - args: ["table", "core_user", "user"], - }); - const roles = await client.execute({ - sql: "SELECT id FROM core_role ORDER BY id", - args: [], - }); - client.close(); - if (result.rows.length !== 2) process.exit(1); - if ( - JSON.stringify(roles.rows.map((row) => row.id)) !== - JSON.stringify(["core.owner", "core.participant"]) - ) process.exit(1); -' "file:$TEST_DIR/selected.db" + import { MikroORM, SqliteDriver } from "@mikro-orm/sqlite"; + import { coreModels, authEntities } from "@hackkit/core"; + const orm = await MikroORM.init({driver: SqliteDriver, dbName: process.argv[1], entities: [...Object.values(coreModels), ...authEntities]}); + try { + const roles = await orm.em.fork().find(coreModels.role, {}, {orderBy: {id: "asc"}}); + if (JSON.stringify(roles.map(role => role.id)) !== JSON.stringify(["core.owner", "core.participant"])) process.exitCode = 1; + } finally { await orm.close(); } +' "$TEST_DIR/selected.db" echo "Database workflow isolation checks passed." diff --git a/apps/web/scripts/test-runtime.mjs b/apps/web/scripts/test-runtime.mjs new file mode 100644 index 00000000..90566660 --- /dev/null +++ b/apps/web/scripts/test-runtime.mjs @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { setTimeout } from "node:timers/promises"; + +// Exercise the production build against a new disposable database on every run. +const directory = await mkdtemp(join(tmpdir(), "hackkit-http-")); +const port = 33017; +const baseURL = `http://127.0.0.1:${port}`; +const env = { + PATH: process.env.PATH, + NODE_ENV: "production", + NEXT_TELEMETRY_DISABLED: "1", + DATABASE_URL: `file:${join(directory, "web.db")}`, + HACKKIT_DATABASE_DIALECT: "sqlite", + NEXT_PUBLIC_APP_URL: baseURL, + BETTER_AUTH_URL: baseURL, + BETTER_AUTH_SECRET: "isolated-http-test-secret-with-at-least-32-characters", + HACKKIT_EMAIL_PROVIDER: "none", + HACKKIT_BLOB_ADAPTER: "s3", + HACKKIT_S3_BUCKET: "test", + HACKKIT_S3_REGION: "auto", + HACKKIT_S3_ENDPOINT: "https://example.invalid", + HACKKIT_S3_ACCESS_KEY_ID: "test", + HACKKIT_S3_SECRET_ACCESS_KEY: "test", + DISCORD_GUILD_ID: "test", + DISCORD_BOT_API_URL: "https://example.invalid", + DISCORD_INTERNAL_AUTH_KEY: "test", + DISCORD_PARTICIPANT_ROLE_ID: "test", +}; + +async function run(...args) { + const command = spawn(process.execPath, args, { env, stdio: "inherit" }); + const [code] = await once(command, "exit"); + assert.equal(code, 0, `Command failed: ${args.join(" ")}`); +} + +let server; +try { + const cli = join( + process.cwd(), + "node_modules/@hackkit/cli/bin/hackkit.mjs", + ); + await run(cli, "db", "migrate"); + await run(cli, "db", "seed"); + server = spawn( + process.execPath, + [ + fileURLToPath(import.meta.resolve("next/dist/bin/next")), + "start", + "-p", + String(port), + "-H", + "127.0.0.1", + ], + { env, stdio: "inherit" }, + ); + let ready = false; + for (let attempt = 0; attempt < 40; attempt++) { + if (server.exitCode !== null) + throw new Error("Next exited before becoming ready."); + try { + const response = await fetch(`${baseURL}/api/auth/get-session`); + ready = response.ok; + } catch { + /* Server may still be starting. */ + } + if (ready) break; + await setTimeout(250); + } + assert.ok(ready, "Next did not become ready."); + + const accounts = []; + for (const name of ["First", "Second"]) { + const response = await fetch(`${baseURL}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json", origin: baseURL }, + body: JSON.stringify({ + name: `HTTP ${name}`, + email: `${name.toLowerCase()}@example.com`, + password: "a sufficiently long password", + }), + }); + assert.equal(response.status, 200); + const body = await response.json(); + const cookie = response.headers + .getSetCookie() + .map((value) => value.split(";")[0]) + .join("; "); + assert.ok(cookie.includes("better-auth.session_token=")); + accounts.push({ id: body.user.id, cookie }); + } + + for (const account of accounts) { + const headers = { cookie: account.cookie }; + const session = await fetch(`${baseURL}/api/auth/get-session`, { + headers, + }).then((response) => response.json()); + assert.equal(session.user.id, account.id); + const page = await fetch(`${baseURL}/onboarding/hacktag`, { + headers, + redirect: "manual", + }); + assert.equal(page.status, 200); + assert.ok(!(await page.text()).includes("data-next-error-message")); + const forbidden = await fetch(`${baseURL}/api/admin/export`, { + headers, + }); + assert.equal(forbidden.status, 403); + } + assert.equal( + await fetch(`${baseURL}/api/auth/get-session`).then((response) => + response.json(), + ), + null, + ); + assert.equal( + (await fetch(`${baseURL}/dashboard`, { redirect: "manual" })).status, + 307, + ); + assert.equal((await fetch(`${baseURL}/api/admin/export`)).status, 401); + console.log( + "Passed: HTTP signup, isolated sessions, onboarding rendering, permission denials, and anonymous redirects.", + ); +} finally { + if (server && server.exitCode === null) { + const exited = once(server, "exit"); + server.kill("SIGTERM"); + await exited; + } + await rm(directory, { recursive: true, force: true }); +} diff --git a/docs/adr/0001-hackkit-core-kernel-and-database-adapter.md b/docs/adr/0001-hackkit-core-kernel-and-database-adapter.md index df45e578..f42b8f4e 100644 --- a/docs/adr/0001-hackkit-core-kernel-and-database-adapter.md +++ b/docs/adr/0001-hackkit-core-kernel-and-database-adapter.md @@ -1,5 +1,7 @@ # Define HackKit Core as a framework-independent kernel with a generic typed database adapter +> Superseded: [ADR 0013](0013-mikroorm-and-core-auth.md) adopts native MikroORM entities and queries, Core-owned Better Auth, and explicit MikroORM migration commands. The historical decision below no longer governs persistence. Domain boundaries and app ownership of reviewed migrations remain. + HackKit Core is the framework-independent kernel for hackathon domain behavior, not the fully built web app or runtime framework. Starter applications import HackKit packages rather than copying core source, while Core exposes module-shaped APIs around Users, User Data, Hackers, Roles, Registration, and future modules. Core accepts a high-level generic `database` adapter whose operations use typed namespaced model descriptors; Core owns its canonical model shapes, and plugins may contribute additional namespaced models, namespaced permissions, and optional API methods through a Better Auth-style `plugins` array passed to `createHackkit`. Public Core APIs enforce authorization internally using actor Auth IDs, including `core.admin` for permission bypass while respecting role hierarchy and `core.super_admin` for full hierarchy bypass. Core will be added alongside the existing apps and packages during the rewrite; existing packages and apps remain unchanged until an explicit migration step. diff --git a/docs/adr/0002-core-storage-schema-and-adapter-factories.md b/docs/adr/0002-core-storage-schema-and-adapter-factories.md index f7e5c7ff..7ec6dbd2 100644 --- a/docs/adr/0002-core-storage-schema-and-adapter-factories.md +++ b/docs/adr/0002-core-storage-schema-and-adapter-factories.md @@ -1,5 +1,7 @@ # Use Core-owned Storage Schema and adapter factories for plugin-aware persistence +> Superseded: [ADR 0013](0013-mikroorm-and-core-auth.md) adopts native MikroORM entities and queries, Core-owned Better Auth, and explicit MikroORM migration commands. The historical decision below no longer governs persistence. Domain boundaries and app ownership of reviewed migrations remain. + HackKit Core owns an adapter-neutral Storage Schema DSL for persistent model descriptors. Core model descriptors combine typed model identity with storage schema, and public record types such as User, Role, and Hacker are inferred from those descriptors. Plugins contribute storage schema using the same adapter-neutral model descriptors rather than dialect-specific database schemas. HackKit Core merges its base Storage Schema with plugin-provided Storage Schema and initializes database adapter factories with the merged registry. Applications configure plugins once through `createHackkit({ plugins: [...] })`; they do not pass plugin configuration separately to each adapter. Database adapters compile or interpret the merged Storage Schema for their concrete backend. The Drizzle adapter will target SQLite/libSQL first through `@hackkit/db-drizzle/sqlite`, while preserving room for future dialect-specific entrypoints. diff --git a/docs/adr/0008-adapter-owned-schema-generation-and-migrations.md b/docs/adr/0008-adapter-owned-schema-generation-and-migrations.md index a2991516..83eb0ee0 100644 --- a/docs/adr/0008-adapter-owned-schema-generation-and-migrations.md +++ b/docs/adr/0008-adapter-owned-schema-generation-and-migrations.md @@ -1,5 +1,7 @@ # Let database adapters generate native schema and applications own migrations +> Superseded: [ADR 0013](0013-mikroorm-and-core-auth.md) adopts native MikroORM entities and queries, Core-owned Better Auth, and explicit MikroORM migration commands. The historical decision below no longer governs persistence. Domain boundaries and app ownership of reviewed migrations remain. + HackKit Core owns an adapter-neutral Storage Schema, but it does not own an ORM schema format or a migration engine. Concrete database adapters compile merged Core and plugin storage into their ecosystem's native schema source. The application commits that generated source and uses its native database tooling to generate, review, and apply migrations. The ownership chain is: diff --git a/docs/adr/0010-hackkit-logger.md b/docs/adr/0010-hackkit-logger.md index c1f2e4c9..f5f8350f 100644 --- a/docs/adr/0010-hackkit-logger.md +++ b/docs/adr/0010-hackkit-logger.md @@ -1,32 +1,32 @@ # HackKit Logger (Better Auth–style, pluggable) -HackKit exposes a replaceable **HackKit Logger** at `createHackkit` and in `hackkit.config.ts`, modeled after Better Auth’s `logger` option: minimum **Log Level**, optional `disabled`, and a custom `log(level, message, ...args)` implementation. Significant **HackKit Core** domain APIs emit structured operational lines at `info`/`debug`; this is not a persisted audit table or the legacy SQL `error_log`. +HackKit exposes a replaceable **HackKit Logger** at `initializeHackkit` and in `hackkit.config.ts`, modeled after Better Auth’s `logger` option: minimum **Log Level**, optional `disabled`, and a custom `log(level, message, ...args)` implementation. Significant **HackKit Core** domain APIs emit structured operational lines at `info`/`debug`; this is not a persisted audit table or the legacy SQL `error_log`. ## Context -- Legacy **apps/web** persisted errors in an `error_log` table via `logError`, separate from framework diagnostics. -- **HackKit Core** had no logging contract; Better Auth already supports its own `logger` on `betterAuth({ ... })`. -- Operators need visibility into domain actions (registration, approval, check-in) without storing PII in the database or coupling Core to a specific vendor (Datadog, Sentry, stdout). +- Legacy **apps/web** persisted errors in an `error_log` table via `logError`, separate from framework diagnostics. +- **HackKit Core** had no logging contract; Better Auth already supports its own `logger` on `betterAuth({ ... })`. +- Operators need visibility into domain actions (registration, approval, check-in) without storing PII in the database or coupling Core to a specific vendor (Datadog, Sentry, stdout). ## Decision -- Define a **HackKit Logger** type in **HackKit Core** with levels `debug` | `info` | `warn` | `error`, `disabled`, and optional custom `log`. -- Ship a default console implementation when the app omits `logger`. -- Default minimum **Log Level**: `info` when `NODE_ENV` is development, `warn` in production, overridable in config. -- **HackKit Core** logs significant domain API calls (e.g. **Hacker Registration**, **Organiser Approval**, **Hackathon Check-in**) with **Log Context**: action name, outcome, **Auth ID**s, stable record ids, and **HackKit** error code/message on failure — never emails, names, or form payloads. -- **HackKit CLI** commands run from a **HackKit Web App** directory read the same `logger` from `hackkit.config.ts`, falling back to the environment-based default when omitted. -- **@hackkit/auth-better-auth** bridges the app’s **HackKit Logger** into Better Auth’s `logger.log` so one custom implementation in config can cover auth and Core output. +- Define a **HackKit Logger** type in **HackKit Core** with levels `debug` | `info` | `warn` | `error`, `disabled`, and optional custom `log`. +- Ship a default console implementation when the app omits `logger`. +- Default minimum **Log Level**: `info` when `NODE_ENV` is development, `warn` in production, overridable in config. +- **HackKit Core** logs significant domain API calls (e.g. **Hacker Registration**, **Organiser Approval**, **Hackathon Check-in**) with **Log Context**: action name, outcome, **Auth ID**s, stable record ids, and **HackKit** error code/message on failure — never emails, names, or form payloads. +- **HackKit CLI** commands run from a **HackKit Web App** directory read the same `logger` from `hackkit.config.ts`, falling back to the environment-based default when omitted. +- **HackKit Core** bridges the app’s **HackKit Logger** into Better Auth’s `logger.log` so one custom implementation in config can cover auth and Core output. ## Alternatives considered -- **Persisted error/audit tables** — Rejected for this milestone; different retention, query, and compliance model than framework logging. -- **Stdout-only, no contract** — Rejected; apps could not forward to hosted logging without forking Core. -- **Independent Better Auth logger only** — Rejected; duplicate config and divergent formats in typical apps. -- **Verbose `debug` payloads (form fields)** — Rejected; PII risk and weak redaction story across custom loggers. +- **Persisted error/audit tables** — Rejected for this milestone; different retention, query, and compliance model than framework logging. +- **Stdout-only, no contract** — Rejected; apps could not forward to hosted logging without forking Core. +- **Independent Better Auth logger only** — Rejected; duplicate config and divergent formats in typical apps. +- **Verbose `debug` payloads (form fields)** — Rejected; PII risk and weak redaction story across custom loggers. ## Consequences -- No SQL `error_log` parity in v1; organisers who need searchable error history may add a custom logger that writes to their store. -- Core gains a small amount of logging overhead on hot paths; keep messages cheap and level-gated. -- Custom loggers must treat **Log Context** as potentially sensitive even without full payloads (Auth IDs are still identifiers). -- Better Auth internal messages and HackKit domain messages share one pipeline when the bridge is used; apps that need separation can pass different implementations to Core vs the auth adapter factory. +- No SQL `error_log` parity in v1; organisers who need searchable error history may add a custom logger that writes to their store. +- Core gains a small amount of logging overhead on hot paths; keep messages cheap and level-gated. +- Custom loggers must treat **Log Context** as potentially sensitive even without full payloads (Auth IDs are still identifiers). +- Better Auth internal messages and HackKit domain messages share one pipeline when the bridge is used; apps that need separation can pass different implementations to Core vs `config.auth.logger`. diff --git a/docs/adr/0013-mikroorm-and-core-auth.md b/docs/adr/0013-mikroorm-and-core-auth.md new file mode 100644 index 00000000..e28e9b83 --- /dev/null +++ b/docs/adr/0013-mikroorm-and-core-auth.md @@ -0,0 +1,37 @@ +# MikroORM as the sole ORM, with Better Auth in Core + +Status: accepted. Supersedes the persistence and authentication boundaries in ADRs 0001, 0002, and 0008. + +## Decision + +Core and server plugins use native MikroORM `defineEntity` definitions and `EntityManager` queries. PostgreSQL, MySQL, SQLite, and libSQL are supported. The generic DatabaseAdapter, adapter factories, custom schema language, schema compiler, and Drizzle packages have been removed. Framework independence, domain operations, authorization, plugin registration, and app ownership of migrations remain. + +`initializeHackkit` collects Core entities, Better Auth entities, enabled plugin entities, and app entities before initializing the ORM. The CLI uses `createOrmOptions` with this same collection. Plugin table names start with the plugin ID converted to snake_case plus an underscore. Duplicate entity names and table names fail initialization. + +One ORM owns shared connections. Each request, action, job, or CLI operation gets a fresh EntityManager. `runtime.createScope(actorAuthId)` binds domain and plugin APIs; `runtime.run(callback, actorAuthId)` also provides MikroORM RequestContext. Transactions use a fresh identity map and clear the parent map on completion. Domain responses are plain DTOs; managed entities and authentication secrets stay on the server. Browser code imports `@hackkit/core/client`. + +## Authentication + +Core constructs Better Auth with `@a77ay/better-auth-mikro-orm`. Better Auth still declares its own unused Drizzle adapter as an upstream dependency; HackKit does not import it, and `drizzle-orm` is absent from the resolved dependency graph. This community MikroORM adapter is pinned alongside Better Auth and MikroORM and covered by real database tests. Apps provide secrets, providers, trusted origins, email behavior, and optional auth plugins. Next owns request headers, cookies, redirects, and auth route handlers. + +Better Auth's user ID is the canonical Auth ID. Its user table owns email. HackKit's profile stores name, profile details, role, approval, and check-in state with a cascading foreign key to the auth user. A MikroORM flush subscriber creates the profile in the same transaction as the identity. Session reads never recreate profiles or copy provider values over profile edits. User Data and Hacker registration remain separate domain records. + +Auth fields and plugin tables must have native entity metadata before startup. Core checks the Better Auth schema requirements and rejects undeclared fields or tables. Additional auth models belong in the shared registry and need reviewed migrations. + +## Consistency and side effects + +Multi-step domain changes run in transactions. A portable operation-lock row serializes capacity and membership decisions before reads. Unique constraints provide additional protection. These locks trade some write concurrency for consistent behavior across supported SQL drivers. + +Notification intents persist with the domain change. Workers claim pending intents with an atomic conditional update before contacting providers. Discord verification commits its linked account and consumed code before external role synchronization. External delivery is not part of a database transaction; provider failures are recorded by existing attempt tracking. An interrupted worker can leave a processing intent requiring operational recovery. + +## Migrations and releases + +Apps commit native MikroORM migrations and snapshots for PostgreSQL, MySQL, and SQLite; libSQL shares SQLite migrations. `hackkit db generate` creates a reviewable migration. `hackkit db migrate` explicitly applies it, and `hackkit db seed` explicitly inserts missing configured roles. Startup and plugin sync do neither. + +For an entity change, generate and review each dialect's migration. Plugin removal may propose dropping tables; review retention requirements before applying that migration. This is a fresh-database redesign. There is no migration of existing deployed data or user accounts. + +## Validation + +The app database matrix applies committed migrations and tests real SQLite, local libSQL, PostgreSQL, MySQL, and HTTP libSQL. It checks schema agreement, signup/session/profile behavior, native JSON/date/boolean/null values, rollback, concurrent registration capacity, teams, uniqueness, and foreign-key cleanup. Package tests cover domain authorization, request isolation, notification claims, and membership conflicts. CI requires external service URLs so those cases cannot silently skip. + +Use Node.js 22.17 or newer and TypeScript 5.9.3. Production validation must also cover the deployment's credentials, TLS, OAuth providers, and external email/Discord services. diff --git a/docs/guides/next-integration.md b/docs/guides/next-integration.md index c3216e02..77ec85ed 100644 --- a/docs/guides/next-integration.md +++ b/docs/guides/next-integration.md @@ -1,91 +1,40 @@ -# Next.js integration path +# Next.js integration -HackKit Web Apps integrate with Core and HackKit UI through **`@hackkit/next`**. After runtime-owned page guards and the collapsed Core action bridge, there is one coherent path for: +Use `@hackkit/next` to connect HackKit Core, Better Auth, and HackKit UI. The [package README](../../packages/next/README.md) describes the API; the [reference app](../../apps/web/README.md) contains setup and database commands. -1. Runtime setup -2. Page guards -3. Core mutations / server actions -4. HackKit UI provider wiring +## Ownership -Package README: [`packages/next/README.md`](../../packages/next/README.md). Reference implementation: [`apps/web`](../../apps/web). +| Concern | Owner | App integration | +| ---------------------------------- | ------------------- | -------------------------------------------------------------- | +| Connections, entities, Better Auth | Core | Supply `database`, `auth`, plugins, and app entities in config | +| Request session and EntityManager | Next runtime | Register `host.getRuntime` as a function | +| Auth HTTP and cookies | Next | Route GET/POST to Core's auth handler | +| Guards and mutations | Request runtime | Resolve `getRuntime()` in layouts and actions | +| Core UI actions | `hackKitUIActions` | Pass to `HackKitUIProvider` | +| Plugin routes/actions | CLI sync | Import generated modules | +| Migrations and seed data | App release process | Explicit CLI commands | -## Why one path +## Request lifecycle -Earlier apps could rebuild page guards locally and duplicate the full Core action surface in both server actions and UI provider maps. That made every new mutation a multi-file app change and let Core, Next, and UI drift apart. +The app creates one host with `createHackkitRuntimeFromConfig({ config })`. Its Core promise owns shared connections and auth. `setHackkitRuntime(host.getRuntime)` registers a factory. Each request resolves its session, forks an EntityManager, and binds domain and plugin APIs to the authenticated actor. Settings are read through that scope. No request state is retained in a module singleton. -The simplified path keeps construction inside `@hackkit/next`: +Auth signup creates the Better Auth identity and HackKit profile in one transaction. Session reads use the existing profile, so user edits survive later sign-ins. Domain methods return plain DTOs; authentication records and managed entities stay on the server. -| Concern | Own it in | App does | -| -------------- | ----------------------- | ------------------------------ | -| Runtime | `createHackkitRuntime*` | Call once, `setHackkitRuntime` | -| Page guards | `runtime.pageGuards` | `getPageGuards()` in layouts | -| Mutations | `runtime.mutations` | Nothing for Core actions | -| UI actions | `hackKitUIActions` | Pass into `HackKitUIProvider` | -| Plugin actions | Generated by CLI sync | Import generated module | +For background jobs, use Core's `runtime.run(callback, actorAuthId)` or create an explicit scope. Close the Core ORM when the process ends. Browser components import `@hackkit/core/client` for schemas, constants, and types. -## Wiring checklist +## Page integration -### Runtime +| Area | Guard | Mutation examples | +| ---------------- | ------------------------------------------ | ---------------------------------------------- | +| Onboarding | `requireOnboardingAccess` | Claim tag, complete User Data, register Hacker | +| Participant | `requireParticipantAccess` | Profile, RSVP, teams | +| Pending approval | `requireApprovalPendingAccess` | Read approval status | +| Admin | `requireNotBanned` and `requirePermission` | Administration, scans, settings | -`apps/web/lib/runtime.ts` is the composition root: +Use the runtime's guards and mutations. Keep the UI provider map in `@hackkit/next`. Plugin actions use their package's `/actions` entrypoint and the same registered runtime. Route maps passed from server components must contain serializable values; custom builders belong in a client navigation adapter. -- `createHackkitRuntimeFromConfig({ config, auth, afterCurrentUser? })` -- `config.database` is an explicit `DatabaseAdapterFactory` (for example `createDrizzleLibsqlAdapter(getDb)`) -- `setHackkitRuntime(runtimePromise)` so `"use server"` actions can resolve mutations -- Thin accessors: `getRuntime`, `getCurrentUser`, `getHackkit`, `getPageGuards` +## Verification -Use `afterCurrentUser` for app-specific work that must run for both `getCurrentUser` and guards (for example owner allowlist provisioning). +Package tests cover action-map parity, redirects, session isolation, and profile preservation. The app matrix validates committed migrations and auth/domain behavior across supported drivers. Run the package tests, `pnpm --filter web typecheck`, and `pnpm --filter web build` after building the web dependency graph. -### Provider - -`apps/web/app/providers.tsx`: - -```tsx - -``` - -Import `hackKitUIActions` from `@hackkit/next`. Do not rebuild the map in the app. - -Omit `routes` (or pass only serializable string overrides) from the Server Component provider. Admin route builders are created inside the client provider defaults. Custom builders belong on a client `navigation` adapter. Server helpers such as onboarding step construction may still use `hackKitUIRoutes` / `DEFAULT_HACKKIT_UI_ROUTES` outside the provider. - -### Layouts and pages - -| Area | Guard / data | UI actions | -| ---------------------------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------- | -| Onboarding | `requireOnboardingAccess` (+ registration-open where needed) | `claimHackTag`, `completeUserData`, `registerHacker` | -| Participant (dashboard, pass, settings, RSVP, teams) | `requireParticipantAccess` | profile, RSVP, Event Pass consumers | -| Approval pending | `requireApprovalPendingAccess` | — | -| Admin | `requireNotBanned` + `requirePermission(Admin)` (+ finer permissions) | admin console, scanners, settings | - -Plugin pages (Teams, Discord, invites) use the registered runtime and generated plugin actions; they are not part of `hackKitUIActions`. - -### Do not - -- Call `createPageGuards` in the app -- Call `createHackKitMutations` in the app -- Hand-maintain a local Core action export list for the provider -- Duplicate mutation wiring when adding a Core UI action — extend `@hackkit/next` instead - -## Drift protection - -`@hackkit/next` tests assert that: - -- `hackKitUIActions` keys match `HackKitUIActions` -- `createHackKitMutations` returns the same key set -- Named exports in `actions.ts` match that key set -- Page guards redirect / not-found for incomplete onboarding, unapproved users, bans, and closed registration - -Compile-time: `hackKitUIActions satisfies HackKitUIActions`. - -```bash -pnpm --filter @hackkit/next test -pnpm --filter @hackkit/next typecheck -pnpm --filter web typecheck -pnpm --filter web build -``` - -## Related ADRs - -- [0005 — Next.js-first UI package](../adr/0005-nextjs-first-ui-package.md) -- [0007 — Event Pass QR in HackKit UI](../adr/0007-event-pass-qr-in-hackkit-ui.md) -- [0012 — Typed hackathon settings registry](../adr/0012-typed-hackathon-settings-registry.md) +See [ADR 0013](../adr/0013-mikroorm-and-core-auth.md) for registry, transaction, authentication, and migration decisions. diff --git a/package.json b/package.json index 71114a0b..f9a94517 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,10 @@ "dev": "turbo run dev", "lint": "turbo run lint", "preinstall": "npx only-allow pnpm", - "schema:generate": "pnpm --filter=web schema:generate", "db:generate": "pnpm --filter=web db:generate", "db:migrate": "pnpm --filter=web db:migrate", "db:seed": "pnpm --filter=web db:seed", "db:reset": "pnpm --filter=web db:reset", - "db:studio": "pnpm --filter=web db:studio", "deploy-discord:dev": "pnpm --filter=bot deploy:dev", "deploy-discord:prod": "pnpm --filter=bot deploy:prod", "format": "prettier --write .", @@ -27,7 +25,9 @@ "packageManager": "pnpm@8.3.1+sha512.d264f6640bf4f09de7cfcc547568515bcf0613cf485a03e8ff16616fa69c4172b6f9a0a2925ee44fb060df565c9c9a8eaf061749e77af318cb77f6684a7051f3", "name": "hackkit", "dependencies": { - "@libsql/client": "^0.14.0", "prettier-plugin-tailwindcss": "^0.6.5" + }, + "engines": { + "node": ">=22.17.0" } } diff --git a/packages/auth-better-auth/.gitignore b/packages/auth-better-auth/.gitignore deleted file mode 100644 index 1521c8b7..00000000 --- a/packages/auth-better-auth/.gitignore +++ /dev/null @@ -1 +0,0 @@ -dist diff --git a/packages/auth-better-auth/package.json b/packages/auth-better-auth/package.json deleted file mode 100644 index ff0d6dcc..00000000 --- a/packages/auth-better-auth/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@hackkit/auth-better-auth", - "version": "0.0.0", - "description": "Better Auth adapter for HackKit", - "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - }, - "./session": { - "types": "./dist/session.d.ts", - "import": "./dist/session.js" - } - }, - "files": [ - "dist" - ], - "scripts": { - "build": "tsc -p tsconfig.json", - "typecheck": "tsc -p tsconfig.json --noEmit" - }, - "dependencies": { - "@hackkit/core": "workspace:*" - }, - "peerDependencies": { - "better-auth": ">=1.6", - "next": ">=14" - }, - "devDependencies": { - "typescript": "5.5.3" - }, - "license": "MIT", - "private": true -} diff --git a/packages/auth-better-auth/src/index.ts b/packages/auth-better-auth/src/index.ts deleted file mode 100644 index 25b65fb7..00000000 --- a/packages/auth-better-auth/src/index.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { - AuthAdapter, - AuthSession, - HackKitLogger, - LogLevel, -} from "@hackkit/core"; -import { headers } from "next/headers"; - -type BetterAuthInstance = { - api: { - getSession: (input: { - headers: Headers; - }) => Promise; - }; -}; - -export type BetterAuthAdapterOptions = { - auth: BetterAuthInstance; - logger?: HackKitLogger; -}; - -export function toBetterAuthLogger(logger: HackKitLogger) { - return { - disabled: logger.disabled, - level: logger.level, - log: (level: LogLevel, message: string, ...args: unknown[]) => { - logger.log(level, message, ...args); - }, - }; -} - -export function betterAuthAdapter( - options: BetterAuthAdapterOptions, -): AuthAdapter { - const { auth } = options; - - return { - async getSession() { - return auth.api.getSession({ - headers: await headers(), - }); - }, - - toAuthId(session) { - return session.user.id; - }, - - getIdentity(session) { - const [firstName = session.user.name, ...lastNameParts] = - session.user.name.trim().split(/\s+/); - - return { - email: session.user.email, - firstName, - lastName: lastNameParts.join(" ") || "User", - profilePhotoUrl: session.user.image ?? undefined, - }; - }, - }; -} diff --git a/packages/auth-better-auth/src/session.ts b/packages/auth-better-auth/src/session.ts deleted file mode 100644 index c316cdf9..00000000 --- a/packages/auth-better-auth/src/session.ts +++ /dev/null @@ -1,18 +0,0 @@ -import "server-only"; - -import { headers } from "next/headers"; -import type { AuthSession } from "@hackkit/core"; - -type BetterAuthInstance = { - api: { - getSession: (input: { - headers: Headers; - }) => Promise; - }; -}; - -export async function getAuthSession(auth: BetterAuthInstance) { - return auth.api.getSession({ - headers: await headers(), - }); -} diff --git a/packages/auth-better-auth/tsconfig.json b/packages/auth-better-auth/tsconfig.json deleted file mode 100644 index e237f6af..00000000 --- a/packages/auth-better-auth/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": "../tsconfig/base.json", - "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "Bundler", - "outDir": "./dist", - "rootDir": "./src", - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "noEmit": false - }, - "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist"] -} diff --git a/packages/blob-local/package.json b/packages/blob-local/package.json index d261163f..0680f41a 100644 --- a/packages/blob-local/package.json +++ b/packages/blob-local/package.json @@ -22,8 +22,8 @@ "@hackkit/core": "workspace:*" }, "devDependencies": { - "@types/node": "20.14.11", - "typescript": "5.5.3" + "@types/node": "22.18.6", + "typescript": "5.9.3" }, "license": "MIT", "private": true diff --git a/packages/blob-s3/package.json b/packages/blob-s3/package.json index 2abe991a..6379b077 100644 --- a/packages/blob-s3/package.json +++ b/packages/blob-s3/package.json @@ -24,8 +24,8 @@ "@hackkit/core": "workspace:*" }, "devDependencies": { - "@types/node": "20.14.11", - "typescript": "5.5.3" + "@types/node": "22.18.6", + "typescript": "5.9.3" }, "license": "MIT", "private": true diff --git a/packages/cli/bin/hackkit.mjs b/packages/cli/bin/hackkit.mjs new file mode 100755 index 00000000..8fb12721 --- /dev/null +++ b/packages/cli/bin/hackkit.mjs @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import "../dist/cli.js"; diff --git a/packages/cli/package.json b/packages/cli/package.json index 7f2c9349..d1b3034c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -4,7 +4,7 @@ "description": "HackKit CLI", "type": "module", "bin": { - "hackkit": "./dist/cli.js" + "hackkit": "./bin/hackkit.mjs" }, "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -16,6 +16,7 @@ } }, "files": [ + "bin", "dist" ], "scripts": { @@ -28,13 +29,17 @@ "@hackkit/core": "workspace:*", "commander": "^12.1.0", "dotenv": "^16.6.1", - "jiti": "^2.4.2" + "jiti": "^2.4.2", + "@mikro-orm/core": "7.2.1", + "@mikro-orm/migrations": "7.2.1", + "prettier": "^3.3.3" }, "devDependencies": { - "@types/node": "20.14.11", + "@types/node": "22.18.6", "tsdown": "^0.22.0", - "typescript": "5.5.3", - "vitest": "^3.2.4" + "typescript": "5.9.3", + "vitest": "^3.2.4", + "@mikro-orm/sqlite": "7.2.1" }, "license": "MIT", "private": true diff --git a/packages/cli/src/__tests__/cli-config.test.ts b/packages/cli/src/__tests__/cli-config.test.ts index c8b830da..a0e74ee4 100644 --- a/packages/cli/src/__tests__/cli-config.test.ts +++ b/packages/cli/src/__tests__/cli-config.test.ts @@ -25,7 +25,8 @@ describe("CLI config loading", () => { await writeFile( join(projectRoot, "hackkit.config.ts"), `export default { - database: { create() { throw new Error("unused"); } }, + auth: {}, + database: { driver: class UnusedDriver {} }, loadedEnvValue: process.env.${testEnvKey}, };`, ); @@ -36,7 +37,8 @@ describe("CLI config loading", () => { }); expect( - (config as typeof config & { loadedEnvValue?: string }).loadedEnvValue, + (config as typeof config & { loadedEnvValue?: string }) + .loadedEnvValue, ).toBe("from-app-env"); }); }); diff --git a/packages/cli/src/__tests__/db-migrations.test.ts b/packages/cli/src/__tests__/db-migrations.test.ts new file mode 100644 index 00000000..cd5682e3 --- /dev/null +++ b/packages/cli/src/__tests__/db-migrations.test.ts @@ -0,0 +1,72 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { MikroORM, defineEntity, p } from "@mikro-orm/core"; +import { SqliteDriver } from "@mikro-orm/sqlite"; +import { createOrmOptions, coreModels } from "@hackkit/core"; +import type { HackkitConfig } from "@hackkit/config"; +import { runDbMigrationGenerate, runDbMigrate } from "../db-migrations"; +import { runDbSeed } from "../db-seed"; + +const directories: string[] = []; +afterEach(async () => { + await Promise.all( + directories + .splice(0) + .map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +it("generates, applies, and seeds native core, plugin, auth, and app entities", async () => { + const projectRoot = await mkdtemp(join(process.cwd(), ".migration-test-")); + directories.push(projectRoot); + const pluginEntity = defineEntity({ + name: "SampleEntry", + tableName: "sample_entry", + properties: { id: p.string().primary(), label: p.string() }, + }); + const appEntity = defineEntity({ + name: "AppNote", + tableName: "app_note", + properties: { id: p.string().primary(), body: p.text() }, + }); + const config = { + database: { + driver: SqliteDriver, + dbName: join(projectRoot, "test.db"), + migrations: { path: join(projectRoot, "migrations"), emit: "ts" }, + }, + auth: { secret: "test-secret-that-is-at-least-32-characters" }, + plugins: [{ id: "sample", entities: [pluginEntity] }], + entities: [appEntity], + seedRoles: [ + { + id: "core.owner", + name: "Owner", + position: 0, + permissions: ["core.superAdmin"], + }, + ], + logger: { disabled: true }, + } satisfies HackkitConfig; + const generated = await runDbMigrationGenerate(config, { + projectRoot, + name: "initial", + }); + expect(generated.diff.up.join("\n")).toContain("core_user"); + expect(generated.diff.up.join("\n")).toContain("sample_entry"); + expect(generated.diff.up.join("\n")).toContain("app_note"); + await runDbMigrate(config); + expect(await runDbMigrate(config)).toHaveLength(0); + expect((await runDbSeed(config)).insertedRoleIds).toEqual(["core.owner"]); + expect((await runDbSeed(config)).skippedRoleIds).toEqual(["core.owner"]); + const orm = await MikroORM.init(createOrmOptions(config)); + try { + expect(await orm.schema.getUpdateSchemaSQL()).toBe(""); + expect(await orm.em.fork().count(coreModels.role, {})).toBe(1); + expect(await orm.em.fork().count(pluginEntity, {})).toBe(0); + expect(await orm.em.fork().count(appEntity, {})).toBe(0); + } finally { + await orm.close(); + } +}); diff --git a/packages/cli/src/__tests__/db-schema.test.ts b/packages/cli/src/__tests__/db-schema.test.ts deleted file mode 100644 index 86ca71fa..00000000 --- a/packages/cli/src/__tests__/db-schema.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { mkdtemp, readFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { defineModel, field, type DatabaseAdapterFactory } from "@hackkit/core"; -import { describe, expect, it } from "vitest"; -import { runDbSchemaGenerate } from "../db-schema"; - -function createConfig(database: DatabaseAdapterFactory) { - const entry = defineModel("sample.entry", { - fields: { id: field.string().primaryKey() }, - }); - return { - database, - plugins: [{ id: "sample", models: { entry } }], - seedRoles: [], - }; -} - -describe("hackkit db schema generate", () => { - it("writes the adapter schema to the default app-owned path", async () => { - const projectRoot = await mkdtemp(join(tmpdir(), "hackkit-schema-")); - const database: DatabaseAdapterFactory = { - create: () => ({}) as never, - schema: { - id: "test-schema", - generateSchemaFiles: () => [ - { - path: "hackkit.ts", - content: "export const generated = true;\n", - }, - ], - }, - }; - - const result = await runDbSchemaGenerate(createConfig(database), { - projectRoot, - }); - - expect(result.outputPath).toBe( - join(projectRoot, "db/schema/hackkit.ts"), - ); - await expect(readFile(result.outputPath, "utf8")).resolves.toBe( - "export const generated = true;\n", - ); - }); - - it("supports an explicit output path", async () => { - const projectRoot = await mkdtemp(join(tmpdir(), "hackkit-schema-")); - const database: DatabaseAdapterFactory = { - create: () => ({}) as never, - schema: { - id: "test-schema", - generateSchemaFiles: () => [ - { path: "hackkit.ts", content: "// schema\n" }, - ], - }, - }; - - const result = await runDbSchemaGenerate(createConfig(database), { - projectRoot, - output: "custom/generated.ts", - }); - - expect(result.outputPath).toBe( - join(projectRoot, "custom/generated.ts"), - ); - await expect(readFile(result.outputPath, "utf8")).resolves.toBe( - "// schema\n", - ); - }); - - it("fails when the configured database cannot generate schema", async () => { - const projectRoot = await mkdtemp(join(tmpdir(), "hackkit-schema-")); - const database: DatabaseAdapterFactory = { - create: () => ({}) as never, - }; - - await expect( - runDbSchemaGenerate(createConfig(database), { projectRoot }), - ).rejects.toThrow( - "Configured database adapter does not support native schema generation.", - ); - }); -}); diff --git a/packages/cli/src/__tests__/db-seed.test.ts b/packages/cli/src/__tests__/db-seed.test.ts deleted file mode 100644 index 5eea6b7b..00000000 --- a/packages/cli/src/__tests__/db-seed.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { - CorePermission, - createHackkit, - createInMemoryDatabaseAdapterFromStorage, - createPluginRegistry, - type DatabaseAdapterFactory, -} from "@hackkit/core"; -import { describe, expect, it } from "vitest"; -import { runDbSeed } from "../db-seed"; - -describe("hackkit db seed", () => { - it("adds configured roles once and reports later runs as skipped", async () => { - const registry = createPluginRegistry(); - const databaseAdapter = createInMemoryDatabaseAdapterFromStorage( - registry.storage, - () => new Date("2026-07-16T12:00:00.000Z"), - () => "generated-id", - ); - const database: DatabaseAdapterFactory = { - create: () => databaseAdapter, - }; - const config = { - database, - seedRoles: [ - { - id: "core.participant", - name: "Participant", - position: 10, - permissions: [], - }, - { - id: "core.owner", - name: "Owner", - position: 0, - permissions: [CorePermission.SuperAdmin], - }, - ], - }; - - await expect(runDbSeed(config)).resolves.toEqual({ - insertedRoleIds: ["core.participant", "core.owner"], - skippedRoleIds: [], - }); - await expect(runDbSeed(config)).resolves.toEqual({ - insertedRoleIds: [], - skippedRoleIds: ["core.participant", "core.owner"], - }); - - const hackkit = createHackkit({ database: databaseAdapter }); - await expect(hackkit.roles.listRoles()).resolves.toMatchObject([ - { id: "core.owner", name: "Owner" }, - { id: "core.participant", name: "Participant" }, - ]); - }); -}); diff --git a/packages/cli/src/__tests__/plugin-actions-scope.test.ts b/packages/cli/src/__tests__/plugin-actions-scope.test.ts new file mode 100644 index 00000000..cf248232 --- /dev/null +++ b/packages/cli/src/__tests__/plugin-actions-scope.test.ts @@ -0,0 +1,38 @@ +import ts from "typescript"; +import { expect, it, vi } from "vitest"; +import { renderPluginActionsFile } from "../plugin-sync"; + +it("resolves each generated action's actor when called, without capturing a module-level request", async () => { + const source = await renderPluginActionsFile([ + { + id: "example", + packageName: "@example/plugin", + actionFactory: "createExampleActions", + actionNames: ["submit"], + }, + ]); + const getRuntime = vi + .fn() + .mockResolvedValueOnce({ actor: "first" }) + .mockResolvedValueOnce({ actor: "second" }); + const imports: Record = { + "@/lib/runtime": { getRuntime }, + "@example/plugin/actions": { + createExampleActions: (runtime: { actor: string }) => ({ + submit: async () => runtime.actor, + }), + }, + }; + const output = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS }, + }).outputText; + const actions: Record Promise> = {}; + new Function("require", "exports", output)( + (name: string) => imports[name], + actions, + ); + expect(getRuntime).not.toHaveBeenCalled(); + expect(await actions.submit()).toBe("first"); + expect(await actions.submit()).toBe("second"); + expect(getRuntime).toHaveBeenCalledTimes(2); +}); diff --git a/packages/cli/src/__tests__/plugin-schema.test.ts b/packages/cli/src/__tests__/plugin-schema.test.ts index 0d57e516..33423db5 100644 --- a/packages/cli/src/__tests__/plugin-schema.test.ts +++ b/packages/cli/src/__tests__/plugin-schema.test.ts @@ -1,3 +1,4 @@ +import { SqliteDriver } from "@mikro-orm/sqlite"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import type { HackkitConfig } from "@hackkit/config"; @@ -5,7 +6,6 @@ import type { HackKitPlugin } from "@hackkit/core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ - generateSchema: vi.fn(), loadConfig: vi.fn(), syncPlugins: vi.fn().mockResolvedValue({ routesWritten: 0, @@ -14,9 +14,6 @@ const mocks = vi.hoisted(() => ({ }), })); -vi.mock("../db-schema", () => ({ - runDbSchemaGenerate: mocks.generateSchema, -})); vi.mock("../cli-config", () => ({ loadConfig: mocks.loadConfig })); vi.mock("../plugin-sync", () => ({ runPluginSync: mocks.syncPlugins })); @@ -30,11 +27,8 @@ const teamsPlugin: HackKitPlugin = { id: "teams", packageName: "@hackkit/plugin-teams", }; -const database = { - create() { - throw new Error("not used by command tests"); - }, -}; +const database = { driver: SqliteDriver, dbName: ":memory:" }; +const auth = { secret: "test-secret-that-is-at-least-32-characters" }; const directories: string[] = []; async function createProject( @@ -71,26 +65,24 @@ afterEach(async () => { }); describe("plugin schema orchestration", () => { - it("regenerates HackKit schema after plugin add", async () => { + it("adds plugin configuration and syncs routes", async () => { const projectRoot = await createProject( "export default { plugins: [] };\n", { "@hackkit/plugin-teams": "workspace:*" }, ); const refreshedConfig: HackkitConfig = { database, + auth, plugins: [teamsPlugin], }; mocks.loadConfig.mockResolvedValue(refreshedConfig); await runPluginAdd( - { projectRoot, config: { database, plugins: [] } }, + { projectRoot, config: { database, auth, plugins: [] } }, "teams", ); expect(mocks.syncPlugins).toHaveBeenCalledOnce(); - expect(mocks.generateSchema).toHaveBeenCalledWith(refreshedConfig, { - projectRoot, - }); const configSource = await readFile( join(projectRoot, "hackkit.config.ts"), "utf8", @@ -99,23 +91,20 @@ describe("plugin schema orchestration", () => { expect(configSource).toContain('from "@hackkit/plugin-teams"'); }); - it("regenerates HackKit schema after plugin removal", async () => { + it("removes plugin configuration and syncs routes", async () => { const projectRoot = await createProject( 'import { teamsPlugin } from "@hackkit/plugin-teams";\nexport default { plugins: [teamsPlugin()] };\n', { "@hackkit/plugin-teams": "workspace:*" }, ); const config: HackkitConfig = { database, + auth, plugins: [teamsPlugin], }; await runPluginRemove({ projectRoot, config }, "teams"); expect(mocks.syncPlugins).toHaveBeenCalledOnce(); - expect(mocks.generateSchema).toHaveBeenCalledWith( - expect.objectContaining({ database, plugins: [] }), - { projectRoot }, - ); const configSource = await readFile( join(projectRoot, "hackkit.config.ts"), "utf8", @@ -127,10 +116,9 @@ describe("plugin schema orchestration", () => { it("does not generate schema during plugin sync", async () => { await runPluginSyncAll({ projectRoot: process.cwd(), - config: { database, plugins: [teamsPlugin] }, + config: { database, auth, plugins: [teamsPlugin] }, }); expect(mocks.syncPlugins).toHaveBeenCalledOnce(); - expect(mocks.generateSchema).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index b70026b5..b4678858 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -27,24 +27,32 @@ db.command("seed") await runDbSeed(config); }); -db.command("schema") - .description("Manage the native HackKit database schema") - .command("generate") - .description("Generate the configured adapter's native HackKit schema") +db.command("generate") + .description("Generate an app-owned MikroORM migration for review") .option("-c, --config ", "path to HackKit config") - .option("-o, --output ", "schema output path") - .action(async (commandOptions: { config?: string; output?: string }) => { - const globalOptions = program.opts<{ config: string }>(); + .option("-n, --name ", "migration name") + .action(async (options: { config?: string; name?: string }) => { const config = await loadConfig( - commandOptions.config ?? globalOptions.config, + options.config ?? program.opts<{ config: string }>().config, ); - const { runDbSchemaGenerate } = await import("./db-schema"); - await runDbSchemaGenerate(config, { + const { runDbMigrationGenerate } = await import("./db-migrations"); + await runDbMigrationGenerate(config, { projectRoot: process.cwd(), - output: commandOptions.output, + name: options.name, }); }); +db.command("migrate") + .description("Apply reviewed MikroORM migrations") + .option("-c, --config ", "path to HackKit config") + .action(async (options: { config?: string }) => { + const config = await loadConfig( + options.config ?? program.opts<{ config: string }>().config, + ); + const { runDbMigrate } = await import("./db-migrations"); + await runDbMigrate(config); + }); + const plugins = program .command("plugin") .description("Manage HackKit plugins in this web app"); diff --git a/packages/cli/src/db-migrations.ts b/packages/cli/src/db-migrations.ts new file mode 100644 index 00000000..9c398c0d --- /dev/null +++ b/packages/cli/src/db-migrations.ts @@ -0,0 +1,44 @@ +import { resolve } from "node:path"; +import type { HackkitConfig } from "./config"; +import { createConfigLogger } from "./logger"; +import { withOrm } from "./orm"; + +export type DbMigrationGenerateOptions = { projectRoot: string; name?: string }; + +/** Write a migration for review. Applying it is a separate explicit command. */ +export async function runDbMigrationGenerate( + config: HackkitConfig, + options: DbMigrationGenerateOptions, +) { + const path = config.database.migrations?.path; + if (!path) + throw new Error( + "Configure database.migrations.path in your app before generating migrations.", + ); + return withOrm(config, async (orm) => { + const result = await orm.migrator.create( + resolve(options.projectRoot, path), + false, + false, + options.name, + ); + createConfigLogger(config).log( + "info", + result.fileName + ? `Generated ${result.fileName}. Review its SQL before running hackkit db migrate.` + : "The database schema has no changes.", + ); + return result; + }); +} + +export async function runDbMigrate(config: HackkitConfig) { + return withOrm(config, async (orm) => { + const migrations = await orm.migrator.up(); + createConfigLogger(config).log( + "info", + `Applied ${migrations.length} migration(s).`, + ); + return migrations; + }); +} diff --git a/packages/cli/src/db-schema.ts b/packages/cli/src/db-schema.ts deleted file mode 100644 index 9110f43c..00000000 --- a/packages/cli/src/db-schema.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; -import { createPluginRegistry } from "@hackkit/core"; -import type { HackkitConfig } from "./config"; -import { createConfigLogger } from "./logger"; - -export type DbSchemaGenerateOptions = { - projectRoot: string; - output?: string; -}; - -export type DbSchemaGenerateResult = { - outputPath: string; - adapterId: string; -}; - -export async function runDbSchemaGenerate( - config: HackkitConfig, - options: DbSchemaGenerateOptions, -): Promise { - const schemaAdapter = config.database.schema; - if (!schemaAdapter) { - throw new Error( - "Configured database adapter does not support native schema generation.", - ); - } - - const registry = createPluginRegistry(config.plugins ?? []); - const generatedFiles = await schemaAdapter.generateSchemaFiles({ - storage: registry.storage, - }); - if (generatedFiles.length !== 1) { - throw new Error( - `Database schema adapter '${schemaAdapter.id}' must generate exactly one schema file.`, - ); - } - - const [generatedFile] = generatedFiles; - const outputPath = resolve( - options.projectRoot, - options.output ?? `db/schema/${generatedFile.path}`, - ); - await mkdir(dirname(outputPath), { recursive: true }); - await writeFile(outputPath, generatedFile.content, "utf8"); - - createConfigLogger(config).log( - "info", - `Generated HackKit schema at ${outputPath}.`, - ); - return { outputPath, adapterId: schemaAdapter.id }; -} diff --git a/packages/cli/src/db-seed.ts b/packages/cli/src/db-seed.ts index 3c86f25d..c1cf67c0 100644 --- a/packages/cli/src/db-seed.ts +++ b/packages/cli/src/db-seed.ts @@ -1,3 +1,4 @@ +import { withOrm } from "./orm"; import { seedRoles } from "@hackkit/core"; import type { SeedRolesResult } from "@hackkit/core"; import type { HackkitConfig } from "./config"; @@ -6,11 +7,15 @@ import { createConfigLogger } from "./logger"; export type DbSeedResult = SeedRolesResult; export async function runDbSeed(config: HackkitConfig): Promise { - const result = await seedRoles({ - database: config.database, - plugins: config.plugins, - roles: config.seedRoles ?? [], - }); + const result = await withOrm(config, (orm) => + orm.em.transactional((em) => + seedRoles({ + em, + roles: config.seedRoles ?? [], + }), + ), + ); + const logger = createConfigLogger(config); for (const roleId of result.insertedRoleIds) { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f0b19507..6a2843c1 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -8,11 +8,8 @@ export type { } from "./config"; export { runDbSeed } from "./db-seed"; export type { DbSeedResult } from "./db-seed"; -export { runDbSchemaGenerate } from "./db-schema"; -export type { - DbSchemaGenerateOptions, - DbSchemaGenerateResult, -} from "./db-schema"; +export { runDbMigrationGenerate, runDbMigrate } from "./db-migrations"; +export type { DbMigrationGenerateOptions } from "./db-migrations"; export { HACKKIT_LOCKFILE, createEmptyLockfile, diff --git a/packages/cli/src/orm.ts b/packages/cli/src/orm.ts new file mode 100644 index 00000000..411c62f2 --- /dev/null +++ b/packages/cli/src/orm.ts @@ -0,0 +1,22 @@ +import { MikroORM } from "@mikro-orm/core"; +import { createOrmOptions, type HackKitDriver } from "@hackkit/core"; +import { createJiti } from "jiti"; +import type { HackkitConfig } from "./config"; + +const jiti = createJiti(import.meta.url); + +/** CLI commands own their connections and always release them. */ +export async function withOrm( + config: HackkitConfig, + operation: (orm: MikroORM) => Promise, +): Promise { + const orm = await MikroORM.init({ + ...createOrmOptions(config), + dynamicImportProvider: (id) => jiti.import(id), + }); + try { + return await operation(orm); + } finally { + await orm.close(); + } +} diff --git a/packages/cli/src/plugin-commands.ts b/packages/cli/src/plugin-commands.ts index 87525080..d9e84e57 100644 --- a/packages/cli/src/plugin-commands.ts +++ b/packages/cli/src/plugin-commands.ts @@ -2,7 +2,6 @@ import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { execSync } from "node:child_process"; import type { HackkitConfig } from "./config"; -import { runDbSchemaGenerate } from "./db-schema"; import { createConfigLogger } from "./logger"; import { PLUGIN_PACKAGE_BY_ID, loadPluginFactory } from "./plugin-manifest"; import { runPluginSync } from "./plugin-sync"; @@ -113,14 +112,17 @@ export async function runPluginAdd( ); const { loadConfig } = await import("./cli-config"); - const refreshedConfig = await loadConfig("hackkit.config.ts"); + const refreshedConfig = await loadConfig("hackkit.config.ts", { + cwd: context.projectRoot, + }); await runPluginSyncAll({ projectRoot: context.projectRoot, config: refreshedConfig, }); - await runDbSchemaGenerate(refreshedConfig, { - projectRoot: context.projectRoot, - }); + createConfigLogger(context.config).log( + "info", + "Plugin configuration updated. Generate and review a database migration with hackkit db generate.", + ); } export async function runPluginRemove( @@ -157,9 +159,10 @@ export async function runPluginRemove( projectRoot: context.projectRoot, plugins: nextConfig.plugins ?? [], }); - await runDbSchemaGenerate(nextConfig, { - projectRoot: context.projectRoot, - }); + createConfigLogger(context.config).log( + "info", + "Plugin configuration updated. Generate and review a database migration with hackkit db generate.", + ); } export async function runPluginSyncAll( diff --git a/packages/cli/src/plugin-sync.ts b/packages/cli/src/plugin-sync.ts index a9c7526b..919b7f64 100644 --- a/packages/cli/src/plugin-sync.ts +++ b/packages/cli/src/plugin-sync.ts @@ -1,4 +1,11 @@ -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { format } from "prettier"; +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { dirname, join } from "node:path"; import type { HackKitPlugin } from "@hackkit/core"; import { @@ -70,14 +77,14 @@ function renderRouteStub( return lines.join("\n"); } -function renderPluginActionsFile( +export async function renderPluginActionsFile( plugins: readonly { id: string; packageName: string; actionFactory?: string; actionNames?: readonly string[]; }[], -): string { +): Promise { const lines = [ '"use server";', "", @@ -89,7 +96,7 @@ function renderPluginActionsFile( for (const plugin of plugins) { if (!plugin.actionFactory || !plugin.actionNames?.length) continue; lines.push( - `import { ${plugin.actionFactory} } from "${plugin.packageName}";`, + `import { ${plugin.actionFactory} } from "${plugin.packageName}/actions";`, ); } @@ -97,17 +104,14 @@ function renderPluginActionsFile( for (const plugin of plugins) { if (!plugin.actionFactory || !plugin.actionNames?.length) continue; - const factoryVar = `${plugin.id}ActionsPromise`; - lines.push( - `const ${factoryVar} = getRuntime().then((runtime) =>`, - ` ${plugin.actionFactory}(runtime),`, - `);`, - "", - ); for (const actionName of plugin.actionNames) { lines.push( - `export async function ${actionName}(...args: Parameters>["${actionName}"]>) {`, - ` const actions = await ${factoryVar};`, + `export async function ${actionName}(`, + `\t...args: Parameters<`, + `\t\tAwaited>["${actionName}"]`, + `\t>`, + `) {`, + ` const actions = await ${plugin.actionFactory}(await getRuntime());`, ` return actions.${actionName}(...args);`, `}`, "", @@ -115,7 +119,11 @@ function renderPluginActionsFile( } } - return lines.join("\n"); + return format(lines.join("\n"), { + parser: "typescript", + useTabs: true, + tabWidth: 4, + }); } function collectRouteClaims( @@ -171,7 +179,10 @@ export async function runPluginSync( id: resolved.plugin.id, package: resolved.packageName, version: resolved.version, - routes: resolved.routes.map(({ host, source }) => ({ host, source })), + routes: resolved.routes.map(({ host, source }) => ({ + host, + source, + })), actions: [...(resolved.plugin.actionNames ?? [])], })), }; @@ -188,10 +199,7 @@ export async function runPluginSync( for (const route of resolved.routes) { const hostPath = join(projectRoot, route.host); nextStubPaths.add(normalizeAppPath(route.host)); - writeGeneratedFile( - hostPath, - renderRouteStub(lockPlugin, route), - ); + writeGeneratedFile(hostPath, renderRouteStub(lockPlugin, route)); routesWritten += 1; } } @@ -211,7 +219,7 @@ export async function runPluginSync( } const actionsPath = join(projectRoot, "app/hackkit-plugin-actions.ts"); - const actionsContent = renderPluginActionsFile( + const actionsContent = await renderPluginActionsFile( resolvedPlugins.map((resolved) => ({ id: resolved.plugin.id, packageName: resolved.packageName, diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts index 7353cf7f..a5ae4579 100644 --- a/packages/cli/tsdown.config.ts +++ b/packages/cli/tsdown.config.ts @@ -10,7 +10,6 @@ export default defineConfig({ platform: "node", fixedExtension: false, deps: { - alwaysBundle: ["@hackkit/core"], onlyBundle: false, }, }); diff --git a/packages/config/package.json b/packages/config/package.json index 741f050a..c98cc73b 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -7,8 +7,8 @@ "types": "./dist/index.d.ts", "exports": { ".": { - "types": "./src/index.ts", - "import": "./src/index.ts", + "types": "./dist/index.d.ts", + "import": "./dist/index.js", "default": "./dist/index.js" } }, @@ -26,9 +26,10 @@ "jiti": "^2.4.2" }, "devDependencies": { - "@types/node": "20.14.11", - "typescript": "5.5.3", - "vitest": "^3.2.4" + "@types/node": "22.18.6", + "typescript": "5.9.3", + "vitest": "^3.2.4", + "@mikro-orm/sqlite": "7.2.1" }, "license": "MIT" } diff --git a/packages/config/src/__tests__/config.test.ts b/packages/config/src/__tests__/config.test.ts index a0bbf55f..98ba3e18 100644 --- a/packages/config/src/__tests__/config.test.ts +++ b/packages/config/src/__tests__/config.test.ts @@ -1,3 +1,4 @@ +import { SqliteDriver } from "@mikro-orm/sqlite"; import { describe, expect, it } from "vitest"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -9,10 +10,11 @@ import { describe("HackKit config", () => { it("normalizes optional collection fields", () => { - const database = { create: () => ({}) as never }; + const database = { driver: SqliteDriver, dbName: ":memory:" }; const config = resolveHackkitConfig( defineHackkitConfig({ database, + auth: { secret: "test-secret-that-is-at-least-32-characters" }, }), ); diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 8d9635b3..a1727883 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -1,5 +1,7 @@ import type { - DatabaseAdapterFactory, + HackKitDatabaseOptions, + HackKitAuthOptions, + InitializeHackkitOptions, EventTypesInput, HackKitLoggerOptions, GroupsInput, @@ -47,7 +49,9 @@ export type HackerRegistrationOptions = { export type HackkitConfig = { plugins?: readonly HackKitPlugin[]; - database: DatabaseAdapterFactory; + database: HackKitDatabaseOptions; + auth: HackKitAuthOptions; + entities?: InitializeHackkitOptions["entities"]; userDataOptions?: UserDataOptionsInput; hackerRegistrationOptions?: HackerRegistrationOptions; eventTypes?: EventTypesInput; @@ -75,9 +79,13 @@ export function defineHackkitConfig( export function resolveHackkitConfig( config: HackkitConfig, ): NormalizedHackkitConfig { - if (!config?.database) { - throw new Error("HackKit config must include a database adapter."); + if (!config?.database?.driver) { + throw new Error( + "HackKit config must include native MikroORM database options and a driver.", + ); } + if (!config.auth) + throw new Error("HackKit config must include Better Auth options."); return { ...config, plugins: config.plugins ?? [], diff --git a/packages/core/package.json b/packages/core/package.json index 28f03091..d2b55c58 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -9,6 +9,14 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./testing": { + "types": "./dist/testing.d.ts", + "import": "./dist/testing.js" + }, + "./client": { + "types": "./dist/client.d.ts", + "import": "./dist/client.js" } }, "files": [ diff --git a/packages/core/src/__tests__/admin-console.test.ts b/packages/core/src/__tests__/admin-console.test.ts index 7c28476a..2e913f90 100644 --- a/packages/core/src/__tests__/admin-console.test.ts +++ b/packages/core/src/__tests__/admin-console.test.ts @@ -1,33 +1,15 @@ +import { + createTestHackkit, + createTestUser, + type TestHackkit, +} from "../testing.js"; import { describe, expect, it } from "vitest"; -import { createInMemoryDatabaseAdapterFromStorage } from "../adapters/db/memory"; -import { createHackkit } from "../hackkit"; -import { CorePermission } from "../permissions"; -import { createPluginRegistry } from "../plugins"; -import { seedTestOwner } from "./seed-test-owner"; - -function createTestHackkit() { - const registry = createPluginRegistry(); - const now = () => new Date("2026-05-24T12:00:00.000Z"); - let counter = 0; - const id = () => `id-${++counter}`; - const db = createInMemoryDatabaseAdapterFromStorage( - registry.storage, - now, - id, - ); - return Object.assign( - createHackkit({ - database: db, - clock: now, - id, - }), - { database: db }, - ); -} +import { CorePermission } from "../permissions.js"; +import { seedTestOwner } from "./seed-test-owner.js"; describe("admin console reads", () => { it("hydrates admin user records and exports flattened rows", async () => { - const hackkit = createTestHackkit(); + const hackkit = await createTestHackkit(); const ownerRole = await seedTestOwner(hackkit); const hackerRole = await hackkit.roles.createRole({ actorAuthId: "owner-auth", @@ -37,7 +19,7 @@ describe("admin console reads", () => { permissions: [CorePermission.HackersRegister], }); - await hackkit.users.ensureUser({ + await createTestUser(hackkit, { authId: "participant-auth", email: "participant@example.com", firstName: "Pat", diff --git a/packages/core/src/__tests__/check-in.test.ts b/packages/core/src/__tests__/check-in.test.ts index 9c21e4c6..47cfad9b 100644 --- a/packages/core/src/__tests__/check-in.test.ts +++ b/packages/core/src/__tests__/check-in.test.ts @@ -1,35 +1,17 @@ +import { + createTestHackkit, + createTestUser, + type TestHackkit, +} from "../testing.js"; import { describe, expect, it } from "vitest"; -import { createPluginRegistry } from "../plugins"; -import { createInMemoryDatabaseAdapterFromStorage } from "../adapters/db/memory"; -import { createHackkit } from "../hackkit"; -import { CorePermission } from "../permissions"; -import { seedTestOwner } from "./seed-test-owner"; - -function createTestHackkit() { - const registry = createPluginRegistry(); - const now = () => new Date("2026-05-24T12:00:00.000Z"); - let counter = 0; - const id = () => `id-${++counter}`; - const db = createInMemoryDatabaseAdapterFromStorage( - registry.storage, - now, - id, - ); - return Object.assign( - createHackkit({ - database: db, - clock: now, - id, - }), - { database: db }, - ); -} +import { CorePermission } from "../permissions.js"; +import { seedTestOwner } from "./seed-test-owner.js"; describe("hackkit access control and check-in", () => { it("records hackathon check-in once", async () => { - const hackkit = createTestHackkit(); + const hackkit = await createTestHackkit(); await seedTestOwner(hackkit, "volunteer-auth"); - await hackkit.users.ensureUser({ + await createTestUser(hackkit, { authId: "participant-auth", email: "p@example.com", firstName: "Pat", @@ -51,9 +33,9 @@ describe("hackkit access control and check-in", () => { }); it("records event scans as separate rows", async () => { - const hackkit = createTestHackkit(); + const hackkit = await createTestHackkit(); await seedTestOwner(hackkit, "volunteer-auth"); - await hackkit.users.ensureUser({ + await createTestUser(hackkit, { authId: "participant-auth", email: "p@example.com", firstName: "Pat", @@ -88,8 +70,8 @@ describe("hackkit access control and check-in", () => { }); it("enforces permissions through accessControl", async () => { - const hackkit = createTestHackkit(); - await hackkit.users.ensureUser({ + const hackkit = await createTestHackkit(); + await createTestUser(hackkit, { authId: "no-role-auth", email: "n@example.com", firstName: "No", diff --git a/packages/core/src/__tests__/groups.test.ts b/packages/core/src/__tests__/groups.test.ts index 8200ee8e..7a4ee19a 100644 --- a/packages/core/src/__tests__/groups.test.ts +++ b/packages/core/src/__tests__/groups.test.ts @@ -1,40 +1,18 @@ +import { + createTestHackkit, + createTestUser, + type TestHackkit, +} from "../testing.js"; import { describe, expect, it } from "vitest"; -import { createInMemoryDatabaseAdapterFromStorage } from "../adapters/db/memory"; -import { createHackkit } from "../hackkit"; -import { createPluginRegistry } from "../plugins"; -import { CoreSetting } from "../settings"; -import { seedTestOwner } from "./seed-test-owner"; - -function createTestHackkit() { - const registry = createPluginRegistry(); - const now = () => new Date("2026-05-24T12:00:00.000Z"); - let counter = 0; - const id = () => `id-${++counter}`; - const db = createInMemoryDatabaseAdapterFromStorage( - registry.storage, - now, - id, - ); - return Object.assign( - createHackkit({ - database: db, - clock: now, - id, - groups: [ - { id: "alpha", label: "Alpha", discordRoleName: "Alpha Role" }, - { id: "beta", label: "Beta", discordRoleName: "Beta Role" }, - ], - }), - { database: db }, - ); -} +import { CoreSetting } from "../settings.js"; +import { seedTestOwner } from "./seed-test-owner.js"; async function seedHacker( - hackkit: ReturnType, + hackkit: TestHackkit, authId: string, group?: string, ) { - await hackkit.users.ensureUser({ + await createTestUser(hackkit, { authId, email: `${authId}@example.com`, firstName: "Test", @@ -65,7 +43,12 @@ async function seedHacker( describe("groups", () => { it("assigns approved hackers across enabled groups without overwriting existing groups", async () => { - const hackkit = createTestHackkit(); + const hackkit = await createTestHackkit({ + groups: [ + { id: "alpha", label: "Alpha", discordRoleName: "Alpha Role" }, + { id: "beta", label: "Beta", discordRoleName: "Beta Role" }, + ], + }); await seedTestOwner(hackkit); await hackkit.settings.setMany({ actorAuthId: "owner-auth", diff --git a/packages/core/src/__tests__/logger.test.ts b/packages/core/src/__tests__/logger.test.ts index c85af30b..949d0fea 100644 --- a/packages/core/src/__tests__/logger.test.ts +++ b/packages/core/src/__tests__/logger.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { createLogger } from "../adapters/logger"; +import { createLogger } from "../adapters/logger.js"; describe("HackKit Logger", () => { it("respects minimum log level", () => { diff --git a/packages/core/src/__tests__/notifications.test.ts b/packages/core/src/__tests__/notifications.test.ts index 9275cf53..51e0de13 100644 --- a/packages/core/src/__tests__/notifications.test.ts +++ b/packages/core/src/__tests__/notifications.test.ts @@ -1,28 +1,20 @@ -import { describe, expect, it } from "vitest"; import { - CoreNotificationKind, - createHackkit, - createInMemoryDatabaseAdapterFromStorage, - createPluginRegistry, - type NotificationChannel, -} from "../index"; - -function createTestHackkit() { - const registry = createPluginRegistry(); - const now = () => new Date("2026-05-24T12:00:00.000Z"); - let counter = 0; - const id = () => `id-${++counter}`; - const db = createInMemoryDatabaseAdapterFromStorage( - registry.storage, - now, - id, - ); - return createHackkit({ database: db, clock: now, id }); -} + createTestHackkit, + createTestUser, + type TestHackkit, +} from "../testing.js"; +import { describe, expect, it } from "vitest"; +import { CoreNotificationKind, type NotificationChannel } from "../index.js"; describe("notifications", () => { it("queues typed core intents and reuses idempotent requests", async () => { - const hackkit = createTestHackkit(); + const hackkit = await createTestHackkit(); + await createTestUser(hackkit, { + authId: "hacker-auth", + email: "hacker@example.com", + firstName: "Test", + lastName: "Hacker", + }); const intent = await hackkit.notifications.queueIntent({ kind: CoreNotificationKind.UserApproved, @@ -51,7 +43,13 @@ describe("notifications", () => { }); it("records one delivery attempt per channel and updates intent status", async () => { - const hackkit = createTestHackkit(); + const hackkit = await createTestHackkit(); + await createTestUser(hackkit, { + authId: "hacker-auth", + email: "hacker@example.com", + firstName: "Test", + lastName: "Hacker", + }); const intent = await hackkit.notifications.queueIntent({ kind: "sample.custom", payload: { authId: "hacker-auth" }, @@ -80,7 +78,9 @@ describe("notifications", () => { status: "delivered", externalId: "message-1", }); - await expect(hackkit.notifications.getIntent(intent.id)).resolves.toMatchObject({ + await expect( + hackkit.notifications.getIntent(intent.id), + ).resolves.toMatchObject({ status: "delivered", }); await expect( @@ -88,3 +88,39 @@ describe("notifications", () => { ).resolves.toHaveLength(1); }); }); + +it("claims each pending intent once across concurrent delivery workers", async () => { + const hackkit = await createTestHackkit(); + const intent = await hackkit.notifications.queueIntent({ + kind: "test.delivery", + payload: {}, + }); + let sends = 0; + const channels = [ + { + id: "test", + async deliver() { + sends++; + return { status: "delivered" as const }; + }, + }, + ]; + const results = await Promise.all( + [1, 2].map(() => + hackkit.runtime + .createScope() + .hackkit.notifications.deliverPending({ channels }), + ), + ); + expect(sends).toBe(1); + expect( + results.reduce((sum, result) => sum + result.intentsProcessed, 0), + ).toBe(1); + const fresh = hackkit.runtime.createScope().hackkit; + expect( + await fresh.notifications.listDeliveryAttempts(intent.id), + ).toHaveLength(1); + expect(await fresh.notifications.getIntent(intent.id)).toMatchObject({ + status: "delivered", + }); +}); diff --git a/packages/core/src/__tests__/public-profile.test.ts b/packages/core/src/__tests__/public-profile.test.ts index 921f6109..92618e50 100644 --- a/packages/core/src/__tests__/public-profile.test.ts +++ b/packages/core/src/__tests__/public-profile.test.ts @@ -1,22 +1,12 @@ +import { + createTestHackkit, + createTestUser, + type TestHackkit, +} from "../testing.js"; import { describe, expect, it } from "vitest"; -import { createInMemoryDatabaseAdapterFromStorage } from "../adapters/db/memory"; -import { createHackkit } from "../hackkit"; -import { createPluginRegistry } from "../plugins"; -function createTestHackkit() { - const registry = createPluginRegistry(); - const now = () => new Date("2026-05-24T12:00:00.000Z"); - const id = () => "id"; - const db = createInMemoryDatabaseAdapterFromStorage( - registry.storage, - now, - id, - ); - return createHackkit({ database: db, clock: now, id }); -} - -async function seedUser(hackkit: ReturnType) { - await hackkit.users.ensureUser({ +async function seedUser(hackkit: TestHackkit) { + await createTestUser(hackkit, { authId: "hacker-auth", email: "hacker@example.com", firstName: "Hack", @@ -47,7 +37,7 @@ async function seedUser(hackkit: ReturnType) { describe("public profiles", () => { it("returns Core-owned profile fields by HackTag when searchable", async () => { - const hackkit = createTestHackkit(); + const hackkit = await createTestHackkit(); await seedUser(hackkit); await hackkit.users.updateProfile({ @@ -61,7 +51,8 @@ describe("public profiles", () => { isProfileSearchable: true, }); - const profile = await hackkit.users.getPublicProfileByHackTag("hackerone"); + const profile = + await hackkit.users.getPublicProfileByHackTag("hackerone"); expect(profile?.user.hackTag).toBe("hackerone"); expect(profile?.user.skills).toEqual(["typescript", "design"]); @@ -70,7 +61,7 @@ describe("public profiles", () => { }); it("hides public profiles when searchability is disabled", async () => { - const hackkit = createTestHackkit(); + const hackkit = await createTestHackkit(); await seedUser(hackkit); await hackkit.users.updateProfile({ authId: "hacker-auth", diff --git a/packages/core/src/__tests__/register-hacker.test.ts b/packages/core/src/__tests__/register-hacker.test.ts index 9c1ed726..a86a5fb7 100644 --- a/packages/core/src/__tests__/register-hacker.test.ts +++ b/packages/core/src/__tests__/register-hacker.test.ts @@ -1,38 +1,15 @@ +import { + createTestHackkit, + createTestUser, + type TestHackkit, +} from "../testing.js"; import { describe, expect, it } from "vitest"; -import { createPluginRegistry } from "../plugins"; -import { createInMemoryDatabaseAdapterFromStorage } from "../adapters/db/memory"; -import { createHackkit } from "../hackkit"; -import { CorePermission } from "../permissions"; -import { CoreSetting } from "../settings"; -import { seedTestOwner } from "./seed-test-owner"; - -function createTestHackkit( - options: { - defaultCompetitorRoleId?: string; - } = {}, -) { - const registry = createPluginRegistry(); - const now = () => new Date("2026-05-24T12:00:00.000Z"); - let counter = 0; - const id = () => `id-${++counter}`; - const db = createInMemoryDatabaseAdapterFromStorage( - registry.storage, - now, - id, - ); - return Object.assign( - createHackkit({ - database: db, - clock: now, - id, - defaultCompetitorRoleId: options.defaultCompetitorRoleId, - }), - { database: db }, - ); -} +import { CorePermission } from "../permissions.js"; +import { CoreSetting } from "../settings.js"; +import { seedTestOwner } from "./seed-test-owner.js"; async function seedParticipantRole( - hackkit: ReturnType, + hackkit: TestHackkit, roleId = "core.participant", ) { return hackkit.roles.createRole({ @@ -45,14 +22,14 @@ async function seedParticipantRole( } async function setRequireApproval( - hackkit: ReturnType, + hackkit: TestHackkit, requireApproval: boolean, ) { await setSetting(hackkit, CoreSetting.RequireApproval, requireApproval); } async function setSetting( - hackkit: ReturnType, + hackkit: TestHackkit, key: CoreSetting, value: boolean | number, ) { @@ -64,11 +41,11 @@ async function setSetting( } async function seedUserWithData( - hackkit: ReturnType, + hackkit: TestHackkit, authId = "hacker-auth", hackTag = "hacker1", ) { - await hackkit.users.ensureUser({ + await createTestUser(hackkit, { authId, email: `${authId}@example.com`, firstName: "Hack", @@ -92,10 +69,7 @@ async function seedUserWithData( }); } -async function registerHacker( - hackkit: ReturnType, - authId = "hacker-auth", -) { +async function registerHacker(hackkit: TestHackkit, authId = "hacker-auth") { return hackkit.hackers.registerHacker({ authId, university: "Test U", @@ -108,7 +82,7 @@ async function registerHacker( describe("registerHacker onboarding side effects", () => { it("assigns default role and auto-approves when requireApproval is false", async () => { - const hackkit = createTestHackkit({ + const hackkit = await createTestHackkit({ defaultCompetitorRoleId: "core.participant", }); await seedTestOwner(hackkit, "admin-auth"); @@ -130,7 +104,7 @@ describe("registerHacker onboarding side effects", () => { }); it("persists app-relative stored file references as resumeUrl", async () => { - const hackkit = createTestHackkit({ + const hackkit = await createTestHackkit({ defaultCompetitorRoleId: "core.participant", }); await seedTestOwner(hackkit, "admin-auth"); @@ -153,7 +127,7 @@ describe("registerHacker onboarding side effects", () => { }); it("assigns default role but leaves user unapproved when requireApproval is true", async () => { - const hackkit = createTestHackkit({ + const hackkit = await createTestHackkit({ defaultCompetitorRoleId: "core.participant", }); await seedTestOwner(hackkit, "admin-auth"); @@ -176,7 +150,7 @@ describe("registerHacker onboarding side effects", () => { }); it("blocks first-time Hacker Registration when registration is closed but allows updates", async () => { - const hackkit = createTestHackkit(); + const hackkit = await createTestHackkit(); await seedTestOwner(hackkit, "admin-auth"); await seedUserWithData(hackkit); @@ -202,7 +176,7 @@ describe("registerHacker onboarding side effects", () => { }); it("blocks new Hacker Registration after maximum registrations is reached", async () => { - const hackkit = createTestHackkit(); + const hackkit = await createTestHackkit(); await seedTestOwner(hackkit, "admin-auth"); await setSetting(hackkit, CoreSetting.MaximumRegistrations, 1); await seedUserWithData(hackkit, "first-auth", "first"); @@ -217,7 +191,7 @@ describe("registerHacker onboarding side effects", () => { }); it("blocks auto-approval when Hackathon Capacity is reached", async () => { - const hackkit = createTestHackkit(); + const hackkit = await createTestHackkit(); await seedTestOwner(hackkit, "admin-auth"); await setSetting(hackkit, CoreSetting.HackathonCapacity, 1); await seedUserWithData(hackkit, "first-auth", "first"); @@ -232,7 +206,7 @@ describe("registerHacker onboarding side effects", () => { }); it("blocks manual Organiser Approval when Hackathon Capacity is reached", async () => { - const hackkit = createTestHackkit(); + const hackkit = await createTestHackkit(); await seedTestOwner(hackkit, "admin-auth"); await setRequireApproval(hackkit, true); await setSetting(hackkit, CoreSetting.HackathonCapacity, 1); diff --git a/packages/core/src/__tests__/resume-url-schema.test.ts b/packages/core/src/__tests__/resume-url-schema.test.ts index 1b9ca5e1..bbbd5494 100644 --- a/packages/core/src/__tests__/resume-url-schema.test.ts +++ b/packages/core/src/__tests__/resume-url-schema.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { registerHackerSchema } from "../schemas"; +import { registerHackerSchema } from "../schemas.js"; const base = { authId: "hacker-auth", diff --git a/packages/core/src/__tests__/rsvp.test.ts b/packages/core/src/__tests__/rsvp.test.ts index 28ccbd9c..4edcd547 100644 --- a/packages/core/src/__tests__/rsvp.test.ts +++ b/packages/core/src/__tests__/rsvp.test.ts @@ -1,34 +1,14 @@ -import { describe, expect, it } from "vitest"; import { - CoreNotificationKind, - CoreSetting, - createHackkit, - createInMemoryDatabaseAdapterFromStorage, - createPluginRegistry, -} from "../index"; -import { seedTestOwner } from "./seed-test-owner"; - -function createTestHackkit() { - const registry = createPluginRegistry(); - let timestamp = 0; - const now = () => - new Date( - `2026-05-24T12:00:${String(timestamp++).padStart(2, "0")}.000Z`, - ); - let counter = 0; - const id = () => `id-${++counter}`; - const db = createInMemoryDatabaseAdapterFromStorage( - registry.storage, - now, - id, - ); - return Object.assign(createHackkit({ database: db, clock: now, id }), { - database: db, - }); -} + createTestHackkit, + createTestUser, + type TestHackkit, +} from "../testing.js"; +import { describe, expect, it } from "vitest"; +import { CoreNotificationKind, CoreSetting } from "../index.js"; +import { seedTestOwner } from "./seed-test-owner.js"; async function setSetting( - hackkit: ReturnType, + hackkit: TestHackkit, key: CoreSetting, value: boolean | number, ) { @@ -39,11 +19,8 @@ async function setSetting( }); } -async function seedApprovedHacker( - hackkit: ReturnType, - authId: string, -) { - await hackkit.users.ensureUser({ +async function seedApprovedHacker(hackkit: TestHackkit, authId: string) { + await createTestUser(hackkit, { authId, email: `${authId}@example.com`, firstName: authId, @@ -73,7 +50,7 @@ async function seedApprovedHacker( describe("RSVP", () => { it("requires RSVPs to be open and limited to approved hackers", async () => { - const hackkit = createTestHackkit(); + const hackkit = await createTestHackkit(); await seedTestOwner(hackkit, "admin-auth"); await seedApprovedHacker(hackkit, "hacker-auth"); @@ -82,7 +59,7 @@ describe("RSVP", () => { ).rejects.toMatchObject({ code: "INVALID_OPERATION" }); await setSetting(hackkit, CoreSetting.RsvpOpen, true); - await hackkit.users.ensureUser({ + await createTestUser(hackkit, { authId: "unregistered-auth", email: "unregistered@example.com", firstName: "Unregistered", @@ -100,7 +77,7 @@ describe("RSVP", () => { }); it("places over-limit hackers on an ordered waitlist and queues intents", async () => { - const hackkit = createTestHackkit(); + const hackkit = await createTestHackkit(); await seedTestOwner(hackkit, "admin-auth"); await setSetting(hackkit, CoreSetting.RsvpOpen, true); await setSetting(hackkit, CoreSetting.RsvpLimit, 1); @@ -138,7 +115,7 @@ describe("RSVP", () => { }); it("lets admins cancel and promote waitlisted RSVPs", async () => { - const hackkit = createTestHackkit(); + const hackkit = await createTestHackkit(); await seedTestOwner(hackkit, "admin-auth"); await setSetting(hackkit, CoreSetting.RsvpOpen, true); await setSetting(hackkit, CoreSetting.RsvpLimit, 1); diff --git a/packages/core/src/__tests__/seed-test-owner.ts b/packages/core/src/__tests__/seed-test-owner.ts index c47152e1..8e17a477 100644 --- a/packages/core/src/__tests__/seed-test-owner.ts +++ b/packages/core/src/__tests__/seed-test-owner.ts @@ -1,19 +1,16 @@ -import type { DatabaseAdapter } from "../database"; -import type { HackKit } from "../hackkit"; -import { coreModels } from "../models"; -import { CorePermission } from "../permissions"; -import { seedRoles } from "../seed"; +import { createTestUser, type TestHackkit } from "../testing.js"; +import { coreModels } from "../models.js"; +import { CorePermission } from "../permissions.js"; +import { seedRoles } from "../seed.js"; -export type TestHackkit = HackKit & { - database: DatabaseAdapter; -}; +export type { TestHackkit } from "../testing.js"; export async function seedTestOwner( hackkit: TestHackkit, authId = "owner-auth", ) { await seedRoles({ - database: hackkit.database, + em: hackkit.em, roles: [ { id: "core.owner", @@ -23,16 +20,14 @@ export async function seedTestOwner( }, ], }); - await hackkit.users.ensureUser({ + await createTestUser(hackkit, { authId, email: `${authId}@example.com`, firstName: "Test", lastName: "Owner", }); - await hackkit.database.update( - coreModels.user, - { authId }, - { roleId: "core.owner", updatedAt: new Date() }, - ); + const owner = await hackkit.em.findOneOrFail(coreModels.user, { authId }); + owner.roleId = "core.owner"; + await hackkit.em.flush(); return (await hackkit.roles.getRole("core.owner"))!; } diff --git a/packages/core/src/access-control.ts b/packages/core/src/access-control.ts index 638a6e35..08908086 100644 --- a/packages/core/src/access-control.ts +++ b/packages/core/src/access-control.ts @@ -1,6 +1,6 @@ -import { HackKitError } from "./errors"; -import { CorePermission, hasPermission, hasSuperAdmin } from "./permissions"; -import type { AuthId, PermissionKey, Role, User } from "./types"; +import { HackKitError } from "./errors.js"; +import { CorePermission, hasPermission, hasSuperAdmin } from "./permissions.js"; +import type { AuthId, PermissionKey, Role, User } from "./types.js"; export type AccessPrincipal = { user: User; diff --git a/packages/core/src/adapters/auth.ts b/packages/core/src/adapters/auth.ts deleted file mode 100644 index e1d73929..00000000 --- a/packages/core/src/adapters/auth.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { AuthId } from "../types"; - -export type AuthSession = { - user: { - id: string; - email: string; - name: string; - image?: string | null; - }; -}; - -export type AuthIdentity = { - email: string; - firstName: string; - lastName: string; - profilePhotoUrl?: string; -}; - -export type AuthAdapter = { - getSession(): Promise; - toAuthId(session: AuthSession): AuthId; - getIdentity(session: AuthSession): AuthIdentity; -}; diff --git a/packages/core/src/adapters/db/memory.ts b/packages/core/src/adapters/db/memory.ts deleted file mode 100644 index 300f040a..00000000 --- a/packages/core/src/adapters/db/memory.ts +++ /dev/null @@ -1,137 +0,0 @@ -import type { - DatabaseAdapter, - DatabaseAdapterFactory, - DatabaseAdapterFactoryContext, - FindManyOptions, - InferInsert, - InferSelect, - Model, - StorageRegistry, -} from "../../database"; - -type Row = Record; - -function applyDefaults( - model: TModel, - value: InferInsert, - context: DatabaseAdapterFactoryContext, -): InferSelect { - const row: Record = { ...value }; - - for (const [name, field] of Object.entries(model.schema.fields)) { - if (row[name] !== undefined) continue; - - const defaultValue = field.defaultValue; - if (!defaultValue) continue; - - switch (defaultValue.kind) { - case "static": - row[name] = defaultValue.value; - break; - case "now": - row[name] = context.now(); - break; - case "id": - row[name] = context.id(); - break; - } - } - - return row as InferSelect; -} - -function matchesWhere(row: Row, where: Record): boolean { - return Object.entries(where).every(([key, value]) => row[key] === value); -} - -function createMemoryAdapter( - context: DatabaseAdapterFactoryContext, -): DatabaseAdapter { - const tables = new Map(); - - function getTable(modelKey: string): Row[] { - const existing = tables.get(modelKey); - if (existing) return existing; - const created: Row[] = []; - tables.set(modelKey, created); - return created; - } - - return { - async insert(model, value) { - const row = applyDefaults(model, value, context); - getTable(model.key).push({ ...row }); - return row as InferSelect; - }, - - async findOne(model, where) { - const [row] = await this.findMany(model, { where, limit: 1 }); - return row ?? null; - }, - - async findMany(model, options) { - let rows = [...getTable(model.key)]; - if (options?.where) { - rows = rows.filter((row) => - matchesWhere(row, options.where as Record), - ); - } - if (options?.orderBy) { - const { field, direction } = options.orderBy; - const fieldName = field as string; - rows.sort((left, right) => { - const leftValue = left[fieldName]; - const rightValue = right[fieldName]; - if (leftValue === rightValue) return 0; - if (leftValue == null && rightValue == null) return 0; - if (leftValue == null) return 1; - if (rightValue == null) return -1; - const comparison = - leftValue < rightValue ? -1 : leftValue > rightValue ? 1 : 0; - return direction === "desc" ? -comparison : comparison; - }); - } - if (options?.limit !== undefined) { - rows = rows.slice(0, options.limit); - } - return rows as InferSelect[]; - }, - - async update(model, where, patch) { - const rows = getTable(model.key); - const updated: InferSelect[] = []; - for (const row of rows) { - if (!matchesWhere(row, where as Record)) continue; - Object.assign(row, patch); - updated.push({ ...row } as InferSelect); - } - return updated; - }, - - async delete(model, where) { - const rows = getTable(model.key); - const remaining = rows.filter( - (row) => !matchesWhere(row, where as Record), - ); - const deletedCount = rows.length - remaining.length; - tables.set(model.key, remaining); - return deletedCount; - }, - }; -} - -export function createInMemoryDatabaseAdapter(): DatabaseAdapterFactory { - return { - create(context) { - return createMemoryAdapter(context); - }, - }; -} - -export function createInMemoryDatabaseAdapterFromStorage( - storage: StorageRegistry, - now: () => Date = () => new Date(), - id: () => string = () => crypto.randomUUID(), -): DatabaseAdapter { - return createInMemoryDatabaseAdapter().create({ storage, now, id }); -} diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts new file mode 100644 index 00000000..52065d56 --- /dev/null +++ b/packages/core/src/client.ts @@ -0,0 +1,11 @@ +/** Browser-safe validation, constants, and domain DTO types. No ORM initialization. */ +export { HackKitError, hackKitErrorCodes } from "./errors.js"; +export type { HackKitErrorCode } from "./errors.js"; +export { CorePermission, hasPermission, hasSuperAdmin } from "./permissions.js"; +export * from "./schemas.js"; +export * from "./settings.js"; +export * from "./user-data-options.js"; +export * from "./event-types.js"; +export * from "./groups.js"; +export type * from "./types.js"; +export type { RsvpSummary } from "./functions/rsvp.js"; diff --git a/packages/core/src/database.ts b/packages/core/src/database.ts deleted file mode 100644 index df4a47c1..00000000 --- a/packages/core/src/database.ts +++ /dev/null @@ -1,308 +0,0 @@ -export type ModelKey = `${string}.${string}`; -export type FieldKind = - | "string" - | "integer" - | "number" - | "boolean" - | "date" - | "json" - | "enum"; -export type StorageDefault = - | { kind: "static"; value: TValue } - | { kind: "now" } - | { kind: "id" }; - -export type ReferenceAction = "cascade" | "restrict" | "setNull" | "noAction"; - -export type FieldReference = { - model: TKey; - field: string; - onDelete?: ReferenceAction; - onUpdate?: ReferenceAction; -}; - -export type FieldDefinition< - TValue, - TOptional extends boolean = false, - THasDefault extends boolean = false, -> = { - kind: FieldKind; - isOptional: TOptional; - isPrimaryKey?: boolean; - isUnique?: boolean; - defaultValue?: StorageDefault; - reference?: FieldReference; - enumValues?: readonly string[]; - readonly __value?: TValue; - readonly __hasDefault?: THasDefault; -}; - -export type AnyField = FieldDefinition; - -export type FieldBuilder< - TValue, - TOptional extends boolean = false, - THasDefault extends boolean = false, -> = FieldDefinition & { - optional(): FieldBuilder; - primaryKey(): FieldBuilder; - unique(): FieldBuilder; - default(value: TValue): FieldBuilder; - defaultNow(): TValue extends Date - ? FieldBuilder - : never; - defaultId(): TValue extends string - ? FieldBuilder - : never; - references( - model: TKey, - field: string, - options?: { onDelete?: ReferenceAction; onUpdate?: ReferenceAction }, - ): FieldBuilder; -}; - -function createField< - TValue, - TOptional extends boolean = false, - THasDefault extends boolean = false, ->( - definition: Omit< - FieldDefinition, - "__value" | "__hasDefault" - >, -): FieldBuilder { - const withDefinition = < - TNextValue, - TNextOptional extends boolean, - TNextHasDefault extends boolean, - >( - next: Omit< - FieldDefinition, - "__value" | "__hasDefault" - >, - ) => createField(next); - - return { - ...definition, - optional() { - return withDefinition({ - ...definition, - isOptional: true, - }); - }, - primaryKey() { - return withDefinition({ - ...definition, - isPrimaryKey: true, - }); - }, - unique() { - return withDefinition({ - ...definition, - isUnique: true, - }); - }, - default(value: TValue) { - return withDefinition({ - ...definition, - defaultValue: { kind: "static", value }, - }); - }, - defaultNow() { - return withDefinition({ - ...definition, - defaultValue: { kind: "now" }, - }) as never; - }, - defaultId() { - return withDefinition({ - ...definition, - defaultValue: { kind: "id" }, - }) as never; - }, - references(modelKey, field, options) { - return withDefinition({ - ...definition, - reference: { model: modelKey, field, ...options }, - }); - }, - }; -} - -export const field = { - string: () => createField({ kind: "string", isOptional: false }), - integer: () => createField({ kind: "integer", isOptional: false }), - number: () => createField({ kind: "number", isOptional: false }), - boolean: () => createField({ kind: "boolean", isOptional: false }), - date: () => createField({ kind: "date", isOptional: false }), - json: () => - createField({ kind: "json", isOptional: false }), - enum: ( - values: TValues, - ) => - createField({ - kind: "enum", - isOptional: false, - enumValues: values, - }), -} as const; - -export type FieldValue = - TField extends FieldDefinition - ? TValue - : never; -export type FieldIsOptional = - TField extends FieldDefinition - ? TOptional - : never; -export type FieldHasDefault = - TField extends FieldDefinition - ? THasDefault - : never; - -export type FieldMap = Record; - -export type ModelDefinition = { - fields: TFields; - unique?: readonly (readonly (keyof TFields & string)[])[]; - indexes?: readonly (readonly (keyof TFields & string)[])[]; -}; - -export type Model< - TDefinition extends ModelDefinition = ModelDefinition, - TKey extends ModelKey = ModelKey, -> = { - readonly key: TKey; - readonly schema: TDefinition; -}; - -export type PersistentModel = Model; - -export function defineModel< - const TKey extends ModelKey, - const TDefinition extends ModelDefinition, ->(key: TKey, schema: TDefinition): Model { - return { key, schema }; -} - -export const model = defineModel; - -type OptionalSelectKeys = { - [K in keyof TFields]: FieldIsOptional extends true ? K : never; -}[keyof TFields]; - -type RequiredSelectKeys = Exclude< - keyof TFields, - OptionalSelectKeys ->; - -type OptionalInsertKeys = { - [K in keyof TFields]: FieldIsOptional extends true - ? K - : FieldHasDefault extends true - ? K - : never; -}[keyof TFields]; - -type RequiredInsertKeys = Exclude< - keyof TFields, - OptionalInsertKeys ->; - -export type InferSelect = - TModel extends Model - ? { - [K in RequiredSelectKeys]: FieldValue< - TDefinition["fields"][K] - >; - } & { - [K in OptionalSelectKeys]?: FieldValue< - TDefinition["fields"][K] - >; - } - : never; - -export type InferInsert = - TModel extends Model - ? { - [K in RequiredInsertKeys]: FieldValue< - TDefinition["fields"][K] - >; - } & { - [K in OptionalInsertKeys]?: FieldValue< - TDefinition["fields"][K] - >; - } - : never; - -export type Where = Partial>; - -export type OrderBy = { - field: keyof InferSelect; - direction?: "asc" | "desc"; -}; - -export type FindManyOptions = { - where?: Where; - orderBy?: OrderBy; - limit?: number; -}; - -export type StorageRegistry = { - models: Record; -}; - -export type DatabaseAdapterFactoryContext = { - storage: StorageRegistry; - now: () => Date; - id: () => string; -}; - -export type GeneratedSchemaFile = { - path: string; - content: string; -}; - -export type DatabaseSchemaAdapter = { - id: string; - generateSchemaFiles(input: { - storage: StorageRegistry; - }): GeneratedSchemaFile[] | Promise; -}; - -export type DatabaseAdapter = { - insert( - model: TModel, - value: InferInsert, - ): Promise>; - findOne( - model: TModel, - where: Where, - ): Promise | null>; - findMany( - model: TModel, - options?: FindManyOptions, - ): Promise[]>; - update( - model: TModel, - where: Where, - patch: Partial>, - ): Promise[]>; - delete( - model: TModel, - where: Where, - ): Promise; -}; - -export type DatabaseAdapterFactory = { - create(context: DatabaseAdapterFactoryContext): DatabaseAdapter; - schema?: DatabaseSchemaAdapter; -}; - -export type DatabaseAdapterInput = DatabaseAdapter | DatabaseAdapterFactory; - -export function isDatabaseAdapterFactory( - input: DatabaseAdapterInput, -): input is DatabaseAdapterFactory { - return typeof (input as DatabaseAdapterFactory).create === "function"; -} diff --git a/packages/core/src/domain-log.ts b/packages/core/src/domain-log.ts index ace5e4b0..5b3f2c61 100644 --- a/packages/core/src/domain-log.ts +++ b/packages/core/src/domain-log.ts @@ -1,6 +1,10 @@ -import { HackKitError } from "./errors"; -import type { DomainLogContext, HackKitLogger, LogLevel } from "./adapters/logger"; -import { logDomain } from "./adapters/logger"; +import { HackKitError } from "./errors.js"; +import type { + DomainLogContext, + HackKitLogger, + LogLevel, +} from "./adapters/logger.js"; +import { logDomain } from "./adapters/logger.js"; export async function withDomainLog( logger: HackKitLogger, diff --git a/packages/core/src/event-types.ts b/packages/core/src/event-types.ts index 23bc94f4..b2155a4a 100644 --- a/packages/core/src/event-types.ts +++ b/packages/core/src/event-types.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { HackKitError } from "./errors"; +import { HackKitError } from "./errors.js"; export type EventTypeOption = { value: string; @@ -27,7 +27,9 @@ const eventTypeOptionSchema = z.object({ const eventTypesSchema = z.array(eventTypeOptionSchema).min(1); -function assertUniqueEventTypeValues(options: readonly EventTypeOption[]): void { +function assertUniqueEventTypeValues( + options: readonly EventTypeOption[], +): void { const seen = new Set(); for (const option of options) { if (seen.has(option.value)) { diff --git a/packages/core/src/functions/admin.ts b/packages/core/src/functions/admin.ts index ab36e9d9..80b3e850 100644 --- a/packages/core/src/functions/admin.ts +++ b/packages/core/src/functions/admin.ts @@ -1,55 +1,80 @@ -import type { HackkitRuntimeContext } from "../hackkit-context"; -import { coreModels } from "../models"; -import { CorePermission } from "../permissions"; +import { serialize } from "@mikro-orm/core"; +import { readUser, toUser } from "../mikro/user.js"; +import type { HackkitRuntimeContext } from "../hackkit-context.js"; +import { coreModels } from "../models.js"; +import { CorePermission } from "../permissions.js"; import type { AdminOverview, AdminUserExportRow, AdminUserRecord, AuthId, Role, -} from "../types"; +} from "../types.js"; export type AdminApiContext = Pick< HackkitRuntimeContext, - "db" | "requirePermission" | "getUserOrThrow" + "em" | "requirePermission" | "getUserOrThrow" >; function toDateKey(date: Date): string { return date.toISOString().slice(0, 10); } -function toIsoString(date?: Date): string { +function toIsoString(date?: Date | null): string { return date ? date.toISOString() : ""; } export function createAdminApi(context: AdminApiContext) { - const { db, requirePermission, getUserOrThrow } = context; + const { em, requirePermission, getUserOrThrow } = context; async function listRolesForAdmin(actorAuthId: AuthId): Promise { await requirePermission(actorAuthId, CorePermission.RolesView); - return db.findMany(coreModels.role, { - orderBy: { field: "position", direction: "asc" }, - }); + return serialize( + await em.find( + coreModels.role, + {}, + { orderBy: { position: "asc" } }, + ), + ); } - async function hydrateUser(user: AdminUserRecord["user"]): Promise { + async function hydrateUser( + user: AdminUserRecord["user"], + ): Promise { const [userData, hacker, rsvp, role, ban] = await Promise.all([ - db.findOne(coreModels.userData, { authId: user.authId }), - db.findOne(coreModels.hacker, { authId: user.authId }), - db.findOne(coreModels.rsvp, { authId: user.authId }), - user.roleId ? db.findOne(coreModels.role, { id: user.roleId }) : null, - db.findOne(coreModels.userBan, { authId: user.authId }), + em.findOne(coreModels.userData, { authId: user.authId }), + em.findOne(coreModels.hacker, { authId: user.authId }), + em.findOne(coreModels.rsvp, { authId: user.authId }), + user.roleId + ? em.findOne(coreModels.role, { id: user.roleId }) + : null, + em.findOne(coreModels.userBan, { authId: user.authId }), ]); - return { user, userData, hacker, rsvp, role, ban }; + return { + user, + userData: userData ? serialize(userData) : null, + hacker: hacker ? serialize(hacker) : null, + rsvp: rsvp ? serialize(rsvp) : null, + role: role ? serialize(role) : null, + ban: ban ? serialize(ban) : null, + }; } - async function listUsers(input: { actorAuthId: AuthId }): Promise { + async function listUsers(input: { + actorAuthId: AuthId; + }): Promise { await requirePermission(input.actorAuthId, CorePermission.UsersView); - const users = await db.findMany(coreModels.user, { - orderBy: { field: "createdAt", direction: "desc" }, - }); - return Promise.all(users.map(hydrateUser)); + const users = await em.find( + coreModels.user, + {}, + { orderBy: { createdAt: "desc" } }, + ); + return Promise.all( + users.map(async (profile) => + hydrateUser(await toUser(em, profile)), + ), + ); } async function getUser(input: { @@ -57,7 +82,7 @@ export function createAdminApi(context: AdminApiContext) { targetAuthId: AuthId; }): Promise { await requirePermission(input.actorAuthId, CorePermission.UsersView); - const user = await db.findOne(coreModels.user, { authId: input.targetAuthId }); + const user = await readUser(em, { authId: input.targetAuthId }); return user ? hydrateUser(user) : null; } @@ -66,13 +91,15 @@ export function createAdminApi(context: AdminApiContext) { hackTag: string; }): Promise { await requirePermission(input.actorAuthId, CorePermission.UsersView); - const user = await db.findOne(coreModels.user, { + const user = await readUser(em, { hackTag: input.hackTag.toLowerCase(), }); return user ? hydrateUser(user) : null; } - async function getOverview(input: { actorAuthId: AuthId }): Promise { + async function getOverview(input: { + actorAuthId: AuthId; + }): Promise { await requirePermission(input.actorAuthId, CorePermission.Admin); const records = await listUsers({ actorAuthId: input.actorAuthId }); const today = new Date(); @@ -93,12 +120,14 @@ export function createAdminApi(context: AdminApiContext) { return { totalUsers: records.length, totalHackers: records.filter((record) => record.hacker).length, - approvedUsers: records.filter((record) => record.user.isApproved).length, + approvedUsers: records.filter((record) => record.user.isApproved) + .length, pendingApprovalUsers: records.filter( (record) => !record.user.isApproved && !record.ban, ).length, bannedUsers: records.filter((record) => record.ban).length, - checkedInUsers: records.filter((record) => record.user.checkedInAt).length, + checkedInUsers: records.filter((record) => record.user.checkedInAt) + .length, confirmedRsvps: records.filter( (record) => record.rsvp?.status === "confirmed", ).length, diff --git a/packages/core/src/functions/events.ts b/packages/core/src/functions/events.ts index 93a1b5c8..69a7d698 100644 --- a/packages/core/src/functions/events.ts +++ b/packages/core/src/functions/events.ts @@ -1,9 +1,10 @@ -import type { HackkitRuntimeContext } from "../hackkit-context"; -import { eventTypeValueSchema } from "../event-types"; -import { HackKitError, parseInput } from "../errors"; -import { withDomainLog } from "../domain-log"; -import { coreModels } from "../models"; -import { CorePermission, hasPermission } from "../permissions"; +import { serialize } from "@mikro-orm/core"; +import type { HackkitRuntimeContext } from "../hackkit-context.js"; +import { eventTypeValueSchema } from "../event-types.js"; +import { HackKitError, parseInput } from "../errors.js"; +import { withDomainLog } from "../domain-log.js"; +import { coreModels } from "../models.js"; +import { CorePermission, hasPermission } from "../permissions.js"; import { createEventSchemaFactory, deleteEventSchema, @@ -11,12 +12,12 @@ import { listEventScansSchema, recordEventScanSchema, updateEventSchemaFactory, -} from "../schemas"; -import type { AuthId, Event, EventScan } from "../types"; +} from "../schemas.js"; +import type { AuthId, Event, EventScan } from "../types.js"; export type EventsApiContext = Pick< HackkitRuntimeContext, - | "db" + | "em" | "now" | "id" | "logger" @@ -52,28 +53,28 @@ export function createEventsApi(context: EventsApiContext) { eventTypeValueSchema(context.eventTypes), ); - const { db, now, id, logger, getUserOrThrow, requirePermission } = context; + const { em, now, id, logger, getUserOrThrow, requirePermission } = context; async function getEventOrThrow(eventId: string): Promise { - const event = await db.findOne(coreModels.event, { id: eventId }); + const event = await em.findOne(coreModels.event, { id: eventId }); if (!event) throw new HackKitError("NOT_FOUND", "Event not found."); - return event; + return serialize(event); } return { options: context.eventTypes, - async listEvents(input?: { - actorAuthId?: AuthId; - }): Promise { - const events = await db.findMany(coreModels.event, { - orderBy: { field: "startTime", direction: "asc" }, - }); + async listEvents(input?: { actorAuthId?: AuthId }): Promise { + const events = await em.find( + coreModels.event, + {}, + { orderBy: { startTime: "asc" } }, + ); const includeHidden = await canViewHiddenEvents( context, input?.actorAuthId, ); - return filterVisibleEvents(events, includeHidden); + return serialize(filterVisibleEvents(events, includeHidden)); }, async getEvent(input: unknown): Promise { @@ -99,19 +100,23 @@ export function createEventsApi(context: EventsApiContext) { CorePermission.EventsCreate, ); const timestamp = now(); - return db.insert(coreModels.event, { - id: id(), - title: parsed.title, - description: parsed.description, - startTime: parsed.startTime, - endTime: parsed.endTime, - location: parsed.location, - type: parsed.type, - host: parsed.host, - hidden: parsed.hidden, - createdAt: timestamp, - updatedAt: timestamp, - }); + { + const created = em.create(coreModels.event, { + id: id(), + title: parsed.title, + description: parsed.description, + startTime: parsed.startTime, + endTime: parsed.endTime, + location: parsed.location, + type: parsed.type, + host: parsed.host, + hidden: parsed.hidden, + createdAt: timestamp, + updatedAt: timestamp, + }); + await em.flush(); + return serialize(created); + } }, ); }, @@ -131,24 +136,30 @@ export function createEventsApi(context: EventsApiContext) { CorePermission.EventsUpdate, ); await getEventOrThrow(parsed.eventId); - const [updated] = await db.update( - coreModels.event, - { id: parsed.eventId }, - { - title: parsed.title, - description: parsed.description, - startTime: parsed.startTime, - endTime: parsed.endTime, - location: parsed.location, - type: parsed.type, - host: parsed.host ?? undefined, - hidden: parsed.hidden, - updatedAt: now(), - }, - ); + const updated = await em.findOne(coreModels.event, { + id: parsed.eventId, + }); + if (updated) { + em.assign( + updated, + { + title: parsed.title, + description: parsed.description, + startTime: parsed.startTime, + endTime: parsed.endTime, + location: parsed.location, + type: parsed.type, + host: parsed.host ?? undefined, + hidden: parsed.hidden, + updatedAt: now(), + }, + { ignoreUndefined: true }, + ); + await em.flush(); + } if (!updated) throw new HackKitError("NOT_FOUND", "Event not found."); - return updated; + return serialize(updated); }, ); }, @@ -167,7 +178,7 @@ export function createEventsApi(context: EventsApiContext) { parsed.actorAuthId, CorePermission.EventsDelete, ); - const deleted = await db.delete(coreModels.event, { + const deleted = await em.nativeDelete(coreModels.event, { id: parsed.eventId, }); if (deleted === 0) @@ -183,15 +194,18 @@ export function createEventsApi(context: EventsApiContext) { CorePermission.EventsScan, ); await getEventOrThrow(parsed.eventId); - return db.findMany(coreModels.eventScan, { - where: { - eventId: parsed.eventId, - ...(parsed.targetAuthId - ? { authId: parsed.targetAuthId } - : {}), - }, - orderBy: { field: "scannedAt", direction: "desc" }, - }); + return serialize( + await em.find( + coreModels.eventScan, + { + eventId: parsed.eventId, + ...(parsed.targetAuthId + ? { authId: parsed.targetAuthId } + : {}), + }, + { orderBy: { scannedAt: "desc" } }, + ), + ); }, async recordEventScan(input: unknown): Promise<{ @@ -216,25 +230,27 @@ export function createEventsApi(context: EventsApiContext) { await getUserOrThrow(parsed.targetAuthId); await getEventOrThrow(parsed.eventId); - const priorScans = await db.findMany(coreModels.eventScan, { - where: { + const priorScans = await em.find( + coreModels.eventScan, + { eventId: parsed.eventId, authId: parsed.targetAuthId, }, - orderBy: { field: "scannedAt", direction: "desc" }, - }); + { orderBy: { scannedAt: "desc" } }, + ); - const scan = await db.insert(coreModels.eventScan, { + const scan = em.create(coreModels.eventScan, { id: id(), eventId: parsed.eventId, authId: parsed.targetAuthId, scannedByAuthId: parsed.actorAuthId, scannedAt: now(), }); + await em.flush(); return { - scan, - priorScans, + scan: serialize(scan), + priorScans: serialize(priorScans), hadPriorScans: priorScans.length > 0, }; }, diff --git a/packages/core/src/functions/groups.ts b/packages/core/src/functions/groups.ts index 75878850..4755c61e 100644 --- a/packages/core/src/functions/groups.ts +++ b/packages/core/src/functions/groups.ts @@ -1,11 +1,16 @@ -import { HackKitError } from "../errors"; -import type { HackkitRuntimeContext } from "../hackkit-context"; -import { coreModels } from "../models"; -import type { AuthId, Hacker } from "../types"; -import type { HackkitGroup } from "../groups"; -import { getEnabledGroups } from "../groups"; +import { serialize } from "@mikro-orm/core"; +import { withOperationLock } from "../mikro/operation.js"; +import { HackKitError } from "../errors.js"; +import type { HackkitRuntimeContext } from "../hackkit-context.js"; +import { coreModels } from "../models.js"; +import type { AuthId, Hacker } from "../types.js"; +import type { HackkitGroup } from "../groups.js"; +import { getEnabledGroups } from "../groups.js"; -export type GroupsApiContext = Pick; +export type GroupsApiContext = Pick< + HackkitRuntimeContext, + "em" | "now" | "groups" +>; function countAssignments( hackers: readonly Hacker[], @@ -20,7 +25,7 @@ function countAssignments( } export function createGroupsApi(context: GroupsApiContext) { - const { db, now, groups } = context; + const { em, now, groups } = context; async function assignGroupToHacker( authId: AuthId, @@ -30,15 +35,19 @@ export function createGroupsApi(context: GroupsApiContext) { if (!group) { throw new HackKitError("NOT_FOUND", "Group not found."); } - const [updated] = await db.update( - coreModels.hacker, - { authId }, - { group: group.id, updatedAt: now() }, - ); + const updated = await em.findOne(coreModels.hacker, { authId }); + if (updated) { + em.assign( + updated, + { group: group.id, updatedAt: now() }, + { ignoreUndefined: true }, + ); + await em.flush(); + } if (!updated) { throw new HackKitError("NOT_FOUND", "Hacker not found."); } - return updated; + return serialize(updated); } return { @@ -47,7 +56,7 @@ export function createGroupsApi(context: GroupsApiContext) { }, async getGroupForAuthId(authId: AuthId): Promise { - const hacker = await db.findOne(coreModels.hacker, { authId }); + const hacker = await em.findOne(coreModels.hacker, { authId }); if (!hacker?.group) return null; return groups.find((group) => group.id === hacker.group) ?? null; }, @@ -56,30 +65,39 @@ export function createGroupsApi(context: GroupsApiContext) { authId: AuthId; groupId: string; }): Promise { - return assignGroupToHacker(input.authId, input.groupId); + return withOperationLock(em, "groups", async () => { + return assignGroupToHacker(input.authId, input.groupId); + }); }, async assignNextGroup(authId: AuthId): Promise { - const hacker = await db.findOne(coreModels.hacker, { authId }); - if (!hacker) return null; - if (hacker.group) return hacker; + return withOperationLock(em, "groups", async () => { + const hacker = await em.findOne(coreModels.hacker, { authId }); + if (!hacker) return null; + if (hacker.group) return serialize(hacker); - const enabledGroups = getEnabledGroups(groups); - if (enabledGroups.length === 0) return hacker; + const enabledGroups = getEnabledGroups(groups); + if (enabledGroups.length === 0) return serialize(hacker); - const hackers = await db.findMany(coreModels.hacker); - const counts = countAssignments(hackers, enabledGroups); - const [nextGroup] = [...enabledGroups].sort((left, right) => { - const countDelta = - (counts.get(left.id) ?? 0) - (counts.get(right.id) ?? 0); - if (countDelta !== 0) return countDelta; - return ( - enabledGroups.findIndex((group) => group.id === left.id) - - enabledGroups.findIndex((group) => group.id === right.id) - ); + const hackers = await em.find(coreModels.hacker, {}); + const counts = countAssignments(hackers, enabledGroups); + const [nextGroup] = [...enabledGroups].sort((left, right) => { + const countDelta = + (counts.get(left.id) ?? 0) - + (counts.get(right.id) ?? 0); + if (countDelta !== 0) return countDelta; + return ( + enabledGroups.findIndex( + (group) => group.id === left.id, + ) - + enabledGroups.findIndex( + (group) => group.id === right.id, + ) + ); + }); + if (!nextGroup) return serialize(hacker); + return assignGroupToHacker(authId, nextGroup.id); }); - if (!nextGroup) return hacker; - return assignGroupToHacker(authId, nextGroup.id); }, }; } diff --git a/packages/core/src/functions/registration-policy.ts b/packages/core/src/functions/registration-policy.ts index 5683fe84..a1226733 100644 --- a/packages/core/src/functions/registration-policy.ts +++ b/packages/core/src/functions/registration-policy.ts @@ -1,11 +1,11 @@ -import type { DatabaseAdapter } from "../database"; -import { HackKitError } from "../errors"; -import { coreModels } from "../models"; -import { CoreSetting, type SettingValue } from "../settings"; -import type { AuthId, Hacker } from "../types"; +import type { EntityManager } from "@mikro-orm/core"; +import { HackKitError } from "../errors.js"; +import { coreModels } from "../models.js"; +import { CoreSetting, type SettingValue } from "../settings.js"; +import type { AuthId, Hacker } from "../types.js"; type RegistrationPolicyContext = { - db: DatabaseAdapter; + em: EntityManager; getSettingValue: (key: CoreSetting) => Promise; }; @@ -20,40 +20,54 @@ function isLimitReached(limit: number, count: number): boolean { export function createCompetitorRegistrationPolicy( context: RegistrationPolicyContext, ) { - const { db, getSettingValue } = context; + const { em, getSettingValue } = context; async function countHackers(where?: Partial): Promise { - return (await db.findMany(coreModels.hacker, where ? { where } : undefined)).length; + return await em.count(coreModels.hacker, where ?? {}); } async function countApprovedHackers(): Promise { - const approvedUsers = await db.findMany(coreModels.user, { - where: { isApproved: true }, + const approvedUsers = await em.find(coreModels.user, { + isApproved: true, }); - const approvedAuthIds = new Set(approvedUsers.map((user) => user.authId)); - return (await db.findMany(coreModels.hacker)).filter((hacker) => + const approvedAuthIds = new Set( + approvedUsers.map((user) => user.authId), + ); + return (await em.find(coreModels.hacker, {})).filter((hacker) => approvedAuthIds.has(hacker.authId), ).length; } async function assertCapacityAvailable(): Promise { - const hackathonCapacity = await getSettingValue(CoreSetting.HackathonCapacity); + const hackathonCapacity = await getSettingValue( + CoreSetting.HackathonCapacity, + ); if ( typeof hackathonCapacity === "number" && isLimitReached(hackathonCapacity, await countApprovedHackers()) ) { - throw new HackKitError("INVALID_OPERATION", "Hackathon capacity has been reached."); + throw new HackKitError( + "INVALID_OPERATION", + "Hackathon capacity has been reached.", + ); } } return { async assertCanRegisterNewHacker(): Promise { - const registrationOpen = await getSettingValue(CoreSetting.RegistrationOpen); + const registrationOpen = await getSettingValue( + CoreSetting.RegistrationOpen, + ); if (!registrationOpen) { - throw new HackKitError("INVALID_OPERATION", "Hacker registration is closed."); + throw new HackKitError( + "INVALID_OPERATION", + "Hacker registration is closed.", + ); } - const maximumRegistrations = await getSettingValue(CoreSetting.MaximumRegistrations); + const maximumRegistrations = await getSettingValue( + CoreSetting.MaximumRegistrations, + ); if ( typeof maximumRegistrations === "number" && isLimitReached(maximumRegistrations, await countHackers()) @@ -75,7 +89,7 @@ export function createCompetitorRegistrationPolicy( }, async assertCanApproveUser(authId: AuthId): Promise { - const hacker = await db.findOne(coreModels.hacker, { authId }); + const hacker = await em.findOne(coreModels.hacker, { authId }); if (!hacker) return; await assertCapacityAvailable(); }, diff --git a/packages/core/src/functions/rsvp.ts b/packages/core/src/functions/rsvp.ts index 502a6f5b..8aea9803 100644 --- a/packages/core/src/functions/rsvp.ts +++ b/packages/core/src/functions/rsvp.ts @@ -1,17 +1,19 @@ -import type { HackkitRuntimeContext } from "../hackkit-context"; -import { CoreNotificationKind } from "../notifications"; -import { HackKitError, parseInput } from "../errors"; -import { withDomainLog } from "../domain-log"; -import { coreModels } from "../models"; -import { CorePermission } from "../permissions"; +import { serialize } from "@mikro-orm/core"; +import { withOperationLock } from "../mikro/operation.js"; +import type { HackkitRuntimeContext } from "../hackkit-context.js"; +import { CoreNotificationKind } from "../notifications.js"; +import { HackKitError, parseInput } from "../errors.js"; +import { withDomainLog } from "../domain-log.js"; +import { coreModels } from "../models.js"; +import { CorePermission } from "../permissions.js"; import { adminCancelRsvpSchema, adminPromoteRsvpSchema, adminSetRsvpStatusSchema, confirmRsvpSchema, -} from "../schemas"; -import { CoreSetting } from "../settings"; -import type { AuthId, Rsvp } from "../types"; +} from "../schemas.js"; +import { CoreSetting } from "../settings.js"; +import type { AuthId, Rsvp } from "../types.js"; export type RsvpSummary = { isOpen: boolean; @@ -24,7 +26,7 @@ export type RsvpSummary = { export type RsvpApiContext = Pick< HackkitRuntimeContext, - | "db" + | "em" | "now" | "logger" | "getUserOrThrow" @@ -54,13 +56,15 @@ function sortWaitlist(rsvps: readonly Rsvp[]): Rsvp[] { }); } -const clearNumber = null as unknown as number; -const clearString = null as unknown as string; -const clearDate = null as unknown as Date; - export function createRsvpApi(context: RsvpApiContext) { - const { db, now, logger, getUserOrThrow, getSettingValue, requirePermission } = - context; + const { + em, + now, + logger, + getUserOrThrow, + getSettingValue, + requirePermission, + } = context; async function getSettings() { const [isOpen, limit, waitlistEnabled] = await Promise.all([ @@ -76,14 +80,21 @@ export function createRsvpApi(context: RsvpApiContext) { } async function listAll(): Promise { - return db.findMany(coreModels.rsvp, { - orderBy: { field: "createdAt", direction: "asc" }, - }); + return serialize( + await em.find( + coreModels.rsvp, + {}, + { orderBy: { createdAt: "asc" } }, + ), + ); } async function nextWaitlistPosition(): Promise { const waitlist = sortWaitlist(await listAll()); - return Math.max(0, ...waitlist.map((rsvp) => rsvp.waitlistPosition ?? 0)) + 1; + return ( + Math.max(0, ...waitlist.map((rsvp) => rsvp.waitlistPosition ?? 0)) + + 1 + ); } async function assertApprovedHacker(authId: AuthId): Promise { @@ -94,7 +105,7 @@ export function createRsvpApi(context: RsvpApiContext) { "Only approved hackers can RSVP.", ); } - const hacker = await db.findOne(coreModels.hacker, { authId }); + const hacker = await em.findOne(coreModels.hacker, { authId }); if (!hacker) { throw new HackKitError( "INVALID_OPERATION", @@ -135,11 +146,18 @@ export function createRsvpApi(context: RsvpApiContext) { for (const [index, rsvp] of waitlist.entries()) { const position = index + 1; if (rsvp.waitlistPosition === position) continue; - await db.update( - coreModels.rsvp, - { authId: rsvp.authId }, - { waitlistPosition: position, updatedAt: now() }, - ); + { + const updatedRows = await em.find(coreModels.rsvp, { + authId: rsvp.authId, + }); + for (const row of updatedRows) + em.assign( + row, + { waitlistPosition: position, updatedAt: now() }, + { ignoreUndefined: true }, + ); + await em.flush(); + } } } @@ -147,7 +165,7 @@ export function createRsvpApi(context: RsvpApiContext) { authId: AuthId, patch: Partial, ): Promise { - const existing = await db.findOne(coreModels.rsvp, { authId }); + const existing = await em.findOne(coreModels.rsvp, { authId }); const timestamp = now(); const value = { ...patch, @@ -155,15 +173,23 @@ export function createRsvpApi(context: RsvpApiContext) { updatedAt: timestamp, }; if (!existing) { - return db.insert(coreModels.rsvp, { - status: "confirmed", - createdAt: timestamp, - ...value, - }); + { + const created = em.create(coreModels.rsvp, { + status: "confirmed", + createdAt: timestamp, + ...value, + }); + await em.flush(); + return serialize(created); + } + } + const updated = await em.findOne(coreModels.rsvp, { authId }); + if (updated) { + em.assign(updated, value, { ignoreUndefined: true }); + await em.flush(); } - const [updated] = await db.update(coreModels.rsvp, { authId }, value); if (!updated) throw new HackKitError("NOT_FOUND", "RSVP not found."); - return updated; + return serialize(updated); } async function getSummary(): Promise { @@ -179,7 +205,8 @@ export function createRsvpApi(context: RsvpApiContext) { waitlistEnabled, confirmedCount, waitlistedCount, - availableSpots: limit === 0 ? null : Math.max(0, limit - confirmedCount), + availableSpots: + limit === 0 ? null : Math.max(0, limit - confirmedCount), }; } @@ -187,178 +214,218 @@ export function createRsvpApi(context: RsvpApiContext) { getSummary, async getRsvp(authId: AuthId): Promise { - return db.findOne(coreModels.rsvp, { authId }); + const record = await em.findOne(coreModels.rsvp, { authId }); + return record ? serialize(record) : null; }, async listRsvps(input?: { actorAuthId?: AuthId }): Promise { if (input?.actorAuthId) { - await requirePermission(input.actorAuthId, CorePermission.UsersView); + await requirePermission( + input.actorAuthId, + CorePermission.UsersView, + ); } return listAll(); }, async confirm(input: unknown): Promise { - const parsed = parseInput(confirmRsvpSchema, input); - return withDomainLog( - logger, - "rsvp.confirm", - { targetAuthId: parsed.authId }, - async () => { - await assertApprovedHacker(parsed.authId); - const settings = await getSettings(); - if (!settings.isOpen) { - throw new HackKitError("INVALID_OPERATION", "RSVPs are closed."); - } - const existing = await db.findOne(coreModels.rsvp, { - authId: parsed.authId, - }); - if (existing && existing.status !== "cancelled") return existing; + return withOperationLock(em, "rsvp", async () => { + const parsed = parseInput(confirmRsvpSchema, input); + return withDomainLog( + logger, + "rsvp.confirm", + { targetAuthId: parsed.authId }, + async () => { + await assertApprovedHacker(parsed.authId); + const settings = await getSettings(); + if (!settings.isOpen) { + throw new HackKitError( + "INVALID_OPERATION", + "RSVPs are closed.", + ); + } + const existing = await em.findOne(coreModels.rsvp, { + authId: parsed.authId, + }); + if (existing && existing.status !== "cancelled") + return serialize(existing); - const active = activeRsvps(await listAll()); - const confirmedCount = confirmedRsvps(active).length; - const timestamp = now(); - const hasCapacity = - settings.limit === 0 || confirmedCount < settings.limit; + const active = activeRsvps(await listAll()); + const confirmedCount = confirmedRsvps(active).length; + const timestamp = now(); + const hasCapacity = + settings.limit === 0 || + confirmedCount < settings.limit; - let rsvp: Rsvp; - if (hasCapacity) { - rsvp = await writeRsvp(parsed.authId, { - status: "confirmed", - waitlistPosition: clearNumber, - confirmedAt: timestamp, - waitlistedAt: clearDate, - cancelledAt: clearDate, - cancelledByAuthId: clearString, - }); - } else if (settings.waitlistEnabled) { - rsvp = await writeRsvp(parsed.authId, { - status: "waitlisted", - waitlistPosition: await nextWaitlistPosition(), - waitlistedAt: timestamp, - confirmedAt: clearDate, - cancelledAt: clearDate, - cancelledByAuthId: clearString, - }); - } else { - throw new HackKitError( - "INVALID_OPERATION", - "RSVP capacity has been reached.", - ); - } + let rsvp: Rsvp; + if (hasCapacity) { + rsvp = await writeRsvp(parsed.authId, { + status: "confirmed", + waitlistPosition: null, + confirmedAt: timestamp, + waitlistedAt: null, + cancelledAt: null, + cancelledByAuthId: null, + }); + } else if (settings.waitlistEnabled) { + rsvp = await writeRsvp(parsed.authId, { + status: "waitlisted", + waitlistPosition: await nextWaitlistPosition(), + waitlistedAt: timestamp, + confirmedAt: null, + cancelledAt: null, + cancelledByAuthId: null, + }); + } else { + throw new HackKitError( + "INVALID_OPERATION", + "RSVP capacity has been reached.", + ); + } - await queueRsvpIntent(rsvp); - return rsvp; - }, - ); + await queueRsvpIntent(rsvp); + return rsvp; + }, + ); + }); }, async cancel(input: unknown): Promise { - const parsed = parseInput(adminCancelRsvpSchema, input); - return withDomainLog( - logger, - "rsvp.cancel", - { - actorAuthId: parsed.actorAuthId, - targetAuthId: parsed.targetAuthId, - }, - async () => { - await requirePermission(parsed.actorAuthId, CorePermission.UsersApprove); - await getUserOrThrow(parsed.targetAuthId); - const timestamp = now(); - const rsvp = await writeRsvp(parsed.targetAuthId, { - status: "cancelled", - waitlistPosition: clearNumber, - cancelledAt: timestamp, - cancelledByAuthId: parsed.actorAuthId, - }); - await renumberWaitlist(); - return rsvp; - }, - ); + return withOperationLock(em, "rsvp", async () => { + const parsed = parseInput(adminCancelRsvpSchema, input); + return withDomainLog( + logger, + "rsvp.cancel", + { + actorAuthId: parsed.actorAuthId, + targetAuthId: parsed.targetAuthId, + }, + async () => { + await requirePermission( + parsed.actorAuthId, + CorePermission.UsersApprove, + ); + await getUserOrThrow(parsed.targetAuthId); + const timestamp = now(); + const rsvp = await writeRsvp(parsed.targetAuthId, { + status: "cancelled", + waitlistPosition: null, + cancelledAt: timestamp, + cancelledByAuthId: parsed.actorAuthId, + }); + await renumberWaitlist(); + return rsvp; + }, + ); + }); }, async setStatus(input: unknown): Promise { - const parsed = parseInput(adminSetRsvpStatusSchema, input); - return withDomainLog( - logger, - "rsvp.setStatus", - { - actorAuthId: parsed.actorAuthId, - targetAuthId: parsed.targetAuthId, - }, - async () => { - await requirePermission(parsed.actorAuthId, CorePermission.UsersApprove); - await assertApprovedHacker(parsed.targetAuthId); - const timestamp = now(); - const patch: Partial = - parsed.status === "confirmed" - ? { - status: "confirmed", - waitlistPosition: clearNumber, - confirmedAt: timestamp, - cancelledAt: clearDate, - cancelledByAuthId: clearString, - } - : parsed.status === "waitlisted" + return withOperationLock(em, "rsvp", async () => { + const parsed = parseInput(adminSetRsvpStatusSchema, input); + return withDomainLog( + logger, + "rsvp.setStatus", + { + actorAuthId: parsed.actorAuthId, + targetAuthId: parsed.targetAuthId, + }, + async () => { + await requirePermission( + parsed.actorAuthId, + CorePermission.UsersApprove, + ); + await assertApprovedHacker(parsed.targetAuthId); + const timestamp = now(); + const patch: Partial = + parsed.status === "confirmed" ? { - status: "waitlisted", - waitlistPosition: await nextWaitlistPosition(), - waitlistedAt: timestamp, - cancelledAt: clearDate, - cancelledByAuthId: clearString, + status: "confirmed", + waitlistPosition: null, + confirmedAt: timestamp, + cancelledAt: null, + cancelledByAuthId: null, } - : { - status: "cancelled", - waitlistPosition: clearNumber, - cancelledAt: timestamp, - cancelledByAuthId: parsed.actorAuthId, - }; - const rsvp = await writeRsvp(parsed.targetAuthId, patch); - await renumberWaitlist(); - await queueRsvpIntent(rsvp); - return rsvp; - }, - ); + : parsed.status === "waitlisted" + ? { + status: "waitlisted", + waitlistPosition: + await nextWaitlistPosition(), + waitlistedAt: timestamp, + cancelledAt: null, + cancelledByAuthId: null, + } + : { + status: "cancelled", + waitlistPosition: null, + cancelledAt: timestamp, + cancelledByAuthId: + parsed.actorAuthId, + }; + const rsvp = await writeRsvp( + parsed.targetAuthId, + patch, + ); + await renumberWaitlist(); + await queueRsvpIntent(rsvp); + return rsvp; + }, + ); + }); }, async promote(input: unknown): Promise { - const parsed = parseInput(adminPromoteRsvpSchema, input); - return withDomainLog( - logger, - "rsvp.promote", - { actorAuthId: parsed.actorAuthId, targetAuthId: parsed.targetAuthId }, - async () => { - await requirePermission(parsed.actorAuthId, CorePermission.UsersApprove); - const summary = await getSummary(); - if (summary.limit !== 0 && summary.confirmedCount >= summary.limit) { - throw new HackKitError( - "INVALID_OPERATION", - "RSVP capacity has been reached.", + return withOperationLock(em, "rsvp", async () => { + const parsed = parseInput(adminPromoteRsvpSchema, input); + return withDomainLog( + logger, + "rsvp.promote", + { + actorAuthId: parsed.actorAuthId, + targetAuthId: parsed.targetAuthId, + }, + async () => { + await requirePermission( + parsed.actorAuthId, + CorePermission.UsersApprove, ); - } - const target = parsed.targetAuthId - ? await db.findOne(coreModels.rsvp, { - authId: parsed.targetAuthId, - status: "waitlisted", - }) - : sortWaitlist(await listAll())[0]; - if (!target) { - throw new HackKitError("NOT_FOUND", "Waitlisted RSVP not found."); - } - await assertApprovedHacker(target.authId); - const timestamp = now(); - const rsvp = await writeRsvp(target.authId, { - status: "confirmed", - waitlistPosition: clearNumber, - confirmedAt: timestamp, - promotedAt: timestamp, - promotedByAuthId: parsed.actorAuthId, - }); - await renumberWaitlist(); - await queueRsvpIntent(rsvp, parsed.actorAuthId); - return rsvp; - }, - ); + const summary = await getSummary(); + if ( + summary.limit !== 0 && + summary.confirmedCount >= summary.limit + ) { + throw new HackKitError( + "INVALID_OPERATION", + "RSVP capacity has been reached.", + ); + } + const target = parsed.targetAuthId + ? await em.findOne(coreModels.rsvp, { + authId: parsed.targetAuthId, + status: "waitlisted", + }) + : sortWaitlist(await listAll())[0]; + if (!target) { + throw new HackKitError( + "NOT_FOUND", + "Waitlisted RSVP not found.", + ); + } + await assertApprovedHacker(target.authId); + const timestamp = now(); + const rsvp = await writeRsvp(target.authId, { + status: "confirmed", + waitlistPosition: null, + confirmedAt: timestamp, + promotedAt: timestamp, + promotedByAuthId: parsed.actorAuthId, + }); + await renumberWaitlist(); + await queueRsvpIntent(rsvp, parsed.actorAuthId); + return rsvp; + }, + ); + }); }, }; } diff --git a/packages/core/src/functions/settings.ts b/packages/core/src/functions/settings.ts index 51d5cd5e..0e802656 100644 --- a/packages/core/src/functions/settings.ts +++ b/packages/core/src/functions/settings.ts @@ -1,20 +1,21 @@ -import type { HackKitLogger } from "../adapters/logger"; -import type { DatabaseAdapter } from "../database"; -import { HackKitError } from "../errors"; -import { coreModels } from "../models"; -import { CorePermission } from "../permissions"; +import { withOperationLock } from "../mikro/operation.js"; +import type { HackKitLogger } from "../adapters/logger.js"; +import type { EntityManager } from "@mikro-orm/core"; +import { HackKitError } from "../errors.js"; +import { coreModels } from "../models.js"; +import { CorePermission } from "../permissions.js"; import type { CoreSettingValueMap, HackathonSettingDefinition, ResolvedHackathonSetting, SettingKey, SettingValue, -} from "../settings"; -import { validateSettingValue } from "../settings"; -import type { AuthId, PermissionKey } from "../types"; +} from "../settings.js"; +import { validateSettingValue } from "../settings.js"; +import type { AuthId, PermissionKey } from "../types.js"; type SettingsApiContext = { - db: DatabaseAdapter; + em: EntityManager; now: () => Date; logger: HackKitLogger; settings: readonly HackathonSettingDefinition[]; @@ -24,11 +25,7 @@ type SettingsApiContext = { ) => Promise; }; -type SettingRow = typeof coreModels.setting extends infer TModel - ? TModel extends import("../database").Model - ? import("../database").InferSelect - : never - : never; +type SettingRow = import("../types.js").HackathonSetting; export type SetHackathonSettingInput = { actorAuthId: AuthId; @@ -51,8 +48,12 @@ export type ResetManyHackathonSettingsInput = { keys: readonly SettingKey[]; }; -function createDefinitionMap(definitions: readonly HackathonSettingDefinition[]) { - return new Map(definitions.map((definition) => [definition.key, definition])); +function createDefinitionMap( + definitions: readonly HackathonSettingDefinition[], +) { + return new Map( + definitions.map((definition) => [definition.key, definition]), + ); } function getDefinition( @@ -61,7 +62,10 @@ function getDefinition( ): HackathonSettingDefinition { const definition = definitions.get(key); if (!definition) { - throw new HackKitError("NOT_FOUND", `Hackathon setting '${key}' is not registered.`); + throw new HackKitError( + "NOT_FOUND", + `Hackathon setting '${key}' is not registered.`, + ); } return definition; } @@ -92,11 +96,14 @@ export function createSettingsApi(context: SettingsApiContext) { const definitions = createDefinitionMap(context.settings); async function requireManage(actorAuthId: AuthId) { - await context.requirePermission(actorAuthId, CorePermission.SettingsManage); + await context.requirePermission( + actorAuthId, + CorePermission.SettingsManage, + ); } async function getRow(key: SettingKey): Promise { - return context.db.findOne(coreModels.setting, { key }); + return context.em.findOne(coreModels.setting, { key }); } async function getValue(key: SettingKey): Promise { @@ -112,20 +119,34 @@ export function createSettingsApi(context: SettingsApiContext) { const timestamp = context.now(); const existing = await getRow(key); if (existing) { - await context.db.update( - coreModels.setting, - { key }, - { value, updatedAt: timestamp, updatedByAuthId: actorAuthId }, - ); + { + const updatedRows = await context.em.find(coreModels.setting, { + key, + }); + for (const row of updatedRows) + context.em.assign( + row, + { + value, + updatedAt: timestamp, + updatedByAuthId: actorAuthId, + }, + { ignoreUndefined: true }, + ); + await context.em.flush(); + } } else { - await context.db.insert(coreModels.setting, { - key, - value, - createdAt: timestamp, - createdByAuthId: actorAuthId, - updatedAt: timestamp, - updatedByAuthId: actorAuthId, - }); + { + context.em.create(coreModels.setting, { + key, + value, + createdAt: timestamp, + createdByAuthId: actorAuthId, + updatedAt: timestamp, + updatedByAuthId: actorAuthId, + }); + await context.em.flush(); + } } context.logger.log("info", "hackathon setting changed", { action: "settings.set", @@ -135,8 +156,11 @@ export function createSettingsApi(context: SettingsApiContext) { }); } - async function resetSetting(actorAuthId: AuthId, key: SettingKey): Promise { - await context.db.delete(coreModels.setting, { key }); + async function resetSetting( + actorAuthId: AuthId, + key: SettingKey, + ): Promise { + await context.em.nativeDelete(coreModels.setting, { key }); context.logger.log("info", "hackathon setting reset", { action: "settings.reset", actorAuthId, @@ -147,27 +171,39 @@ export function createSettingsApi(context: SettingsApiContext) { return { getValue: getValue as { - (key: TKey): Promise; + ( + key: TKey, + ): Promise; (key: SettingKey): Promise; }, - async get(input: { actorAuthId: AuthId; key: SettingKey }): Promise { + async get(input: { + actorAuthId: AuthId; + key: SettingKey; + }): Promise { await requireManage(input.actorAuthId); const definition = getDefinition(definitions, input.key); return resolveSetting(definition, await getRow(input.key)); }, - async list(input: { actorAuthId: AuthId }): Promise { + async list(input: { + actorAuthId: AuthId; + }): Promise { await requireManage(input.actorAuthId); const rows = new Map( - (await context.db.findMany(coreModels.setting)).map((row) => [row.key, row]), + (await context.em.find(coreModels.setting, {})).map((row) => [ + row.key, + row, + ]), ); return context.settings.map((definition) => resolveSetting(definition, rows.get(definition.key) ?? null), ); }, - async set(input: SetHackathonSettingInput): Promise { + async set( + input: SetHackathonSettingInput, + ): Promise { const [setting] = await this.setMany({ actorAuthId: input.actorAuthId, values: [{ key: input.key, value: input.value }], @@ -175,23 +211,37 @@ export function createSettingsApi(context: SettingsApiContext) { return setting; }, - async setMany(input: SetManyHackathonSettingsInput): Promise { - await requireManage(input.actorAuthId); - const parsed = input.values.map(({ key, value }) => { - const definition = getDefinition(definitions, key); - return { key, definition, value: validateSettingValue(definition, value) }; + async setMany( + input: SetManyHackathonSettingsInput, + ): Promise { + return withOperationLock(context.em, "settings", async () => { + await requireManage(input.actorAuthId); + const parsed = input.values.map(({ key, value }) => { + const definition = getDefinition(definitions, key); + return { + key, + definition, + value: validateSettingValue(definition, value), + }; + }); + for (const update of parsed) { + await writeSetting( + input.actorAuthId, + update.key, + update.value, + ); + } + return Promise.all( + parsed.map(async ({ key, definition }) => + resolveSetting(definition, await getRow(key)), + ), + ); }); - for (const update of parsed) { - await writeSetting(input.actorAuthId, update.key, update.value); - } - return Promise.all( - parsed.map(async ({ key, definition }) => - resolveSetting(definition, await getRow(key)), - ), - ); }, - async reset(input: ResetHackathonSettingInput): Promise { + async reset( + input: ResetHackathonSettingInput, + ): Promise { const [setting] = await this.resetMany({ actorAuthId: input.actorAuthId, keys: [input.key], @@ -199,13 +249,21 @@ export function createSettingsApi(context: SettingsApiContext) { return setting; }, - async resetMany(input: ResetManyHackathonSettingsInput): Promise { - await requireManage(input.actorAuthId); - const definitionsToReset = input.keys.map((key) => getDefinition(definitions, key)); - for (const definition of definitionsToReset) { - await resetSetting(input.actorAuthId, definition.key); - } - return definitionsToReset.map((definition) => resolveSetting(definition, null)); + async resetMany( + input: ResetManyHackathonSettingsInput, + ): Promise { + return withOperationLock(context.em, "settings", async () => { + await requireManage(input.actorAuthId); + const definitionsToReset = input.keys.map((key) => + getDefinition(definitions, key), + ); + for (const definition of definitionsToReset) { + await resetSetting(input.actorAuthId, definition.key); + } + return definitionsToReset.map((definition) => + resolveSetting(definition, null), + ); + }); }, }; } diff --git a/packages/core/src/functions/users.ts b/packages/core/src/functions/users.ts index 612d66b9..3842d0a9 100644 --- a/packages/core/src/functions/users.ts +++ b/packages/core/src/functions/users.ts @@ -1,24 +1,26 @@ -import type { HackkitRuntimeContext } from "../hackkit-context"; -import { HackKitError, parseInput } from "../errors"; -import { withDomainLog } from "../domain-log"; -import { coreModels } from "../models"; -import { CorePermission } from "../permissions"; -import type { CompetitorRegistrationPolicy } from "./registration-policy"; +import { serialize } from "@mikro-orm/core"; +import { withOperationLock } from "../mikro/operation.js"; +import { readUser, toUser } from "../mikro/user.js"; +import type { HackkitRuntimeContext } from "../hackkit-context.js"; +import { HackKitError, parseInput } from "../errors.js"; +import { withDomainLog } from "../domain-log.js"; +import { coreModels } from "../models.js"; +import { CorePermission } from "../permissions.js"; +import type { CompetitorRegistrationPolicy } from "./registration-policy.js"; import { approveUserSchema, banUserSchema, checkInUserSchema, claimHackTagSchema, clearCheckInUserSchema, - ensureUserSchema, unbanUserSchema, updateUserProfileSchema, -} from "../schemas"; -import type { AuthId, PublicUserProfile, User, UserBan } from "../types"; +} from "../schemas.js"; +import type { AuthId, PublicUserProfile, User, UserBan } from "../types.js"; export type UsersApiContext = Pick< HackkitRuntimeContext, - | "db" + | "em" | "now" | "logger" | "getUserOrThrow" @@ -32,7 +34,7 @@ export type UsersApiContext = Pick< export function createUsersApi(context: UsersApiContext) { const { - db, + em, now, logger, getUserOrThrow, @@ -44,46 +46,16 @@ export function createUsersApi(context: UsersApiContext) { } = context; return { - async ensureUser(input: unknown): Promise { - const parsed = parseInput(ensureUserSchema, input); - const existing = await db.findOne(coreModels.user, { - authId: parsed.authId, - }); - const timestamp = now(); - - if (!existing) { - const user: User = { - ...parsed, - skills: [], - isProfileSearchable: true, - isApproved: false, - createdAt: timestamp, - updatedAt: timestamp, - }; - return db.insert(coreModels.user, user); - } - - const [updated] = await db.update( - coreModels.user, - { authId: parsed.authId }, - { - email: parsed.email, - firstName: parsed.firstName, - lastName: parsed.lastName, - profilePhotoUrl: existing.profilePhotoUrl ?? parsed.profilePhotoUrl, - updatedAt: timestamp, - }, - ); - return updated ?? { ...existing, ...parsed, updatedAt: timestamp }; - }, - async getUser(authId: AuthId): Promise { - return db.findOne(coreModels.user, { authId }); + return readUser(em, { authId }); }, async getUserByHackTag(hackTag: string): Promise { - const parsedHackTag = parseInput(claimHackTagSchema.shape.hackTag, hackTag); - return db.findOne(coreModels.user, { hackTag: parsedHackTag }); + const parsedHackTag = parseInput( + claimHackTagSchema.shape.hackTag, + hackTag, + ); + return readUser(em, { hackTag: parsedHackTag }); }, async getPublicProfileByHackTag( @@ -92,8 +64,10 @@ export function createUsersApi(context: UsersApiContext) { const user = await this.getUserByHackTag(hackTag); if (!user || !user.isProfileSearchable) return null; const [hacker, role] = await Promise.all([ - db.findOne(coreModels.hacker, { authId: user.authId }), - user.roleId ? db.findOne(coreModels.role, { id: user.roleId }) : null, + em.findOne(coreModels.hacker, { authId: user.authId }), + user.roleId + ? em.findOne(coreModels.role, { id: user.roleId }) + : null, ]); return { user: { @@ -117,12 +91,13 @@ export function createUsersApi(context: UsersApiContext) { personalWebsiteUrl: hacker.personalWebsiteUrl, } : null, - role, + role: role ? serialize(role) : null, }; }, async getUserBan(authId: AuthId): Promise { - return db.findOne(coreModels.userBan, { authId }); + const record = await em.findOne(coreModels.userBan, { authId }); + return record ? serialize(record) : null; }, async listUsers(input?: { actorAuthId?: AuthId }): Promise { @@ -131,53 +106,27 @@ export function createUsersApi(context: UsersApiContext) { input.actorAuthId, CorePermission.UsersView, ); - return db.findMany(coreModels.user, { - orderBy: { field: "createdAt", direction: "desc" }, - }); - }, - - async claimHackTag(input: unknown): Promise { - const parsed = parseInput(claimHackTagSchema, input); - return withDomainLog( - logger, - "users.claimHackTag", - { targetAuthId: parsed.authId }, - async () => { - await getUserOrThrow(parsed.authId); - const existing = await db.findOne(coreModels.user, { - hackTag: parsed.hackTag, - }); - if (existing && existing.authId !== parsed.authId) { - throw new HackKitError( - "CONFLICT", - "HackTag is already claimed.", - ); - } - const [updated] = await db.update( + return Promise.all( + ( + await em.find( coreModels.user, - { authId: parsed.authId }, - { - hackTag: parsed.hackTag, - updatedAt: now(), - }, - ); - if (!updated) - throw new HackKitError("NOT_FOUND", "User not found."); - return updated; - }, + {}, + { orderBy: { createdAt: "desc" } }, + ) + ).map((profile) => toUser(em, profile)), ); }, - async updateProfile(input: unknown): Promise { - const parsed = parseInput(updateUserProfileSchema, input); - return withDomainLog( - logger, - "users.updateProfile", - { targetAuthId: parsed.authId }, - async () => { - await getUserOrThrow(parsed.authId); - if (parsed.hackTag) { - const existing = await db.findOne(coreModels.user, { + async claimHackTag(input: unknown): Promise { + return withOperationLock(em, "profiles", async () => { + const parsed = parseInput(claimHackTagSchema, input); + return withDomainLog( + logger, + "users.claimHackTag", + { targetAuthId: parsed.authId }, + async () => { + await getUserOrThrow(parsed.authId); + const existing = await em.findOne(coreModels.user, { hackTag: parsed.hackTag, }); if (existing && existing.authId !== parsed.authId) { @@ -186,178 +135,283 @@ export function createUsersApi(context: UsersApiContext) { "HackTag is already claimed.", ); } - } - const [updated] = await db.update( - coreModels.user, - { authId: parsed.authId }, - { - firstName: parsed.firstName, - lastName: parsed.lastName, - profilePhotoUrl: parsed.profilePhotoUrl, - hackTag: parsed.hackTag, - bio: parsed.bio, - pronouns: parsed.pronouns, - skills: parsed.skills?.map((skill) => skill.toLowerCase()), - isProfileSearchable: parsed.isProfileSearchable, - discordDisplayHandle: parsed.discordDisplayHandle, - updatedAt: now(), - }, - ); - if (!updated) - throw new HackKitError("NOT_FOUND", "User not found."); - return updated; - }, - ); + const updated = await em.findOne(coreModels.user, { + authId: parsed.authId, + }); + if (updated) { + em.assign( + updated, + { + hackTag: parsed.hackTag, + updatedAt: now(), + }, + { ignoreUndefined: true }, + ); + await em.flush(); + } + if (!updated) + throw new HackKitError( + "NOT_FOUND", + "User not found.", + ); + return getUserOrThrow(updated.authId); + }, + ); + }); + }, + + async updateProfile(input: unknown): Promise { + return withOperationLock(em, "profiles", async () => { + const parsed = parseInput(updateUserProfileSchema, input); + return withDomainLog( + logger, + "users.updateProfile", + { targetAuthId: parsed.authId }, + async () => { + await getUserOrThrow(parsed.authId); + if (parsed.hackTag) { + const existing = await em.findOne(coreModels.user, { + hackTag: parsed.hackTag, + }); + if (existing && existing.authId !== parsed.authId) { + throw new HackKitError( + "CONFLICT", + "HackTag is already claimed.", + ); + } + } + const updated = await em.findOne(coreModels.user, { + authId: parsed.authId, + }); + if (updated) { + em.assign( + updated, + { + firstName: parsed.firstName, + lastName: parsed.lastName, + profilePhotoUrl: parsed.profilePhotoUrl, + hackTag: parsed.hackTag, + bio: parsed.bio, + pronouns: parsed.pronouns, + skills: parsed.skills?.map((skill) => + skill.toLowerCase(), + ), + isProfileSearchable: + parsed.isProfileSearchable, + discordDisplayHandle: + parsed.discordDisplayHandle, + updatedAt: now(), + }, + { ignoreUndefined: true }, + ); + await em.flush(); + } + if (!updated) + throw new HackKitError( + "NOT_FOUND", + "User not found.", + ); + return getUserOrThrow(updated.authId); + }, + ); + }); }, async approveUser(input: unknown): Promise { - const parsed = parseInput(approveUserSchema, input); - return withDomainLog( - logger, - "users.approveUser", - { - actorAuthId: parsed.actorAuthId, - targetAuthId: parsed.targetAuthId, - }, - async () => { - const principal = await requirePermission( - parsed.actorAuthId, - CorePermission.UsersApprove, - ); - const target = await getUserOrThrow(parsed.targetAuthId); - if (parsed.approved && !target.isApproved) { - await registrationPolicy.assertCanApproveUser(parsed.targetAuthId); - } - if (target.roleId) - assertCanManageRole( - principal, - await getRoleOrThrow(target.roleId), + return withOperationLock(em, "registration", async () => { + const parsed = parseInput(approveUserSchema, input); + return withDomainLog( + logger, + "users.approveUser", + { + actorAuthId: parsed.actorAuthId, + targetAuthId: parsed.targetAuthId, + }, + async () => { + const principal = await requirePermission( + parsed.actorAuthId, + CorePermission.UsersApprove, ); - const [updated] = await db.update( - coreModels.user, - { authId: parsed.targetAuthId }, - { - isApproved: parsed.approved, - updatedAt: now(), - }, - ); - if (!updated) - throw new HackKitError("NOT_FOUND", "User not found."); - if (parsed.approved) { - await groups.assignNextGroup(parsed.targetAuthId); - } - return updated; - }, - ); + const target = await getUserOrThrow( + parsed.targetAuthId, + ); + if (parsed.approved && !target.isApproved) { + await registrationPolicy.assertCanApproveUser( + parsed.targetAuthId, + ); + } + if (target.roleId) + assertCanManageRole( + principal, + await getRoleOrThrow(target.roleId), + ); + const updated = await em.findOne(coreModels.user, { + authId: parsed.targetAuthId, + }); + if (updated) { + em.assign( + updated, + { + isApproved: parsed.approved, + updatedAt: now(), + }, + { ignoreUndefined: true }, + ); + await em.flush(); + } + if (!updated) + throw new HackKitError( + "NOT_FOUND", + "User not found.", + ); + if (parsed.approved) { + await groups.assignNextGroup(parsed.targetAuthId); + } + return getUserOrThrow(updated.authId); + }, + ); + }); }, async banUser(input: unknown): Promise { - const parsed = parseInput(banUserSchema, input); - const principal = await requirePermission( - parsed.actorAuthId, - CorePermission.UsersBan, - ); - const target = await getUserOrThrow(parsed.targetAuthId); - if (target.roleId) - assertCanManageRole( - principal, - await getRoleOrThrow(target.roleId), + return withOperationLock(em, "bans", async () => { + const parsed = parseInput(banUserSchema, input); + const principal = await requirePermission( + parsed.actorAuthId, + CorePermission.UsersBan, ); - const existing = await db.findOne(coreModels.userBan, { - authId: parsed.targetAuthId, - }); - if (existing) return existing; - return db.insert(coreModels.userBan, { - authId: parsed.targetAuthId, - reason: parsed.reason, - bannedByAuthId: parsed.actorAuthId, - createdAt: now(), + const target = await getUserOrThrow(parsed.targetAuthId); + if (target.roleId) + assertCanManageRole( + principal, + await getRoleOrThrow(target.roleId), + ); + const existing = await em.findOne(coreModels.userBan, { + authId: parsed.targetAuthId, + }); + if (existing) return existing; + { + const created = em.create(coreModels.userBan, { + authId: parsed.targetAuthId, + reason: parsed.reason, + bannedByAuthId: parsed.actorAuthId, + createdAt: now(), + }); + await em.flush(); + return serialize(created); + } }); }, async unbanUser(input: unknown): Promise { - const parsed = parseInput(unbanUserSchema, input); - const principal = await requirePermission( - parsed.actorAuthId, - CorePermission.UsersBan, - ); - const target = await getUserOrThrow(parsed.targetAuthId); - if (target.roleId) - assertCanManageRole( - principal, - await getRoleOrThrow(target.roleId), + return withOperationLock(em, "bans", async () => { + const parsed = parseInput(unbanUserSchema, input); + const principal = await requirePermission( + parsed.actorAuthId, + CorePermission.UsersBan, ); - await db.delete(coreModels.userBan, { - authId: parsed.targetAuthId, + const target = await getUserOrThrow(parsed.targetAuthId); + if (target.roleId) + assertCanManageRole( + principal, + await getRoleOrThrow(target.roleId), + ); + await em.nativeDelete(coreModels.userBan, { + authId: parsed.targetAuthId, + }); }); }, async checkIn(input: unknown): Promise { - const parsed = parseInput(checkInUserSchema, input); - return withDomainLog( - logger, - "users.checkIn", - { - actorAuthId: parsed.actorAuthId, - targetAuthId: parsed.targetAuthId, - }, - async () => { - await requirePermission( - parsed.actorAuthId, - CorePermission.UsersCheckIn, - ); - const target = await getUserOrThrow(parsed.targetAuthId); - if (target.checkedInAt) { - throw new HackKitError( - "INVALID_OPERATION", - "User is already checked in.", + return withOperationLock(em, "check-in", async () => { + const parsed = parseInput(checkInUserSchema, input); + return withDomainLog( + logger, + "users.checkIn", + { + actorAuthId: parsed.actorAuthId, + targetAuthId: parsed.targetAuthId, + }, + async () => { + await requirePermission( + parsed.actorAuthId, + CorePermission.UsersCheckIn, ); - } - const timestamp = now(); - const [updated] = await db.update( - coreModels.user, - { authId: parsed.targetAuthId }, - { - checkedInAt: timestamp, - updatedAt: timestamp, - }, - ); - if (!updated) - throw new HackKitError("NOT_FOUND", "User not found."); - return updated; - }, - ); + const target = await getUserOrThrow( + parsed.targetAuthId, + ); + if (target.checkedInAt) { + throw new HackKitError( + "INVALID_OPERATION", + "User is already checked in.", + ); + } + const timestamp = now(); + const updated = await em.findOne(coreModels.user, { + authId: parsed.targetAuthId, + }); + if (updated) { + em.assign( + updated, + { + checkedInAt: timestamp, + updatedAt: timestamp, + }, + { ignoreUndefined: true }, + ); + await em.flush(); + } + if (!updated) + throw new HackKitError( + "NOT_FOUND", + "User not found.", + ); + return getUserOrThrow(updated.authId); + }, + ); + }); }, async clearCheckIn(input: unknown): Promise { - const parsed = parseInput(clearCheckInUserSchema, input); - return withDomainLog( - logger, - "users.clearCheckIn", - { - actorAuthId: parsed.actorAuthId, - targetAuthId: parsed.targetAuthId, - }, - async () => { - await requirePermission( - parsed.actorAuthId, - CorePermission.UsersCheckIn, - ); - await getUserOrThrow(parsed.targetAuthId); - const timestamp = now(); - const [updated] = await db.update( - coreModels.user, - { authId: parsed.targetAuthId }, - { - checkedInAt: null as unknown as Date, - updatedAt: timestamp, - }, - ); - if (!updated) - throw new HackKitError("NOT_FOUND", "User not found."); - return updated; - }, - ); + return withOperationLock(em, "check-in", async () => { + const parsed = parseInput(clearCheckInUserSchema, input); + return withDomainLog( + logger, + "users.clearCheckIn", + { + actorAuthId: parsed.actorAuthId, + targetAuthId: parsed.targetAuthId, + }, + async () => { + await requirePermission( + parsed.actorAuthId, + CorePermission.UsersCheckIn, + ); + await getUserOrThrow(parsed.targetAuthId); + const timestamp = now(); + const updated = await em.findOne(coreModels.user, { + authId: parsed.targetAuthId, + }); + if (updated) { + em.assign( + updated, + { + checkedInAt: null, + updatedAt: timestamp, + }, + { ignoreUndefined: true }, + ); + await em.flush(); + } + if (!updated) + throw new HackKitError( + "NOT_FOUND", + "User not found.", + ); + return getUserOrThrow(updated.authId); + }, + ); + }); }, }; } diff --git a/packages/core/src/groups.ts b/packages/core/src/groups.ts index 9527f8cf..a69f1077 100644 --- a/packages/core/src/groups.ts +++ b/packages/core/src/groups.ts @@ -1,4 +1,4 @@ -import { HackKitError } from "./errors"; +import { HackKitError } from "./errors.js"; export type HackkitGroupInput = { id: string; @@ -22,19 +22,24 @@ function normalizeGroupId(id: string): string { return id.trim(); } -export function resolveGroups(input: GroupsInput = []): readonly HackkitGroup[] { +export function resolveGroups( + input: GroupsInput = [], +): readonly HackkitGroup[] { const seen = new Set(); return input.map((group) => { - const value = - typeof group === "string" - ? { id: group } - : group; + const value = typeof group === "string" ? { id: group } : group; const id = normalizeGroupId(value.id); if (!id) { - throw new HackKitError("INVALID_OPERATION", "Group id is required."); + throw new HackKitError( + "INVALID_OPERATION", + "Group id is required.", + ); } if (seen.has(id)) { - throw new HackKitError("CONFLICT", `Group '${id}' is already configured.`); + throw new HackKitError( + "CONFLICT", + `Group '${id}' is already configured.`, + ); } seen.add(id); return { diff --git a/packages/core/src/hackkit-context.ts b/packages/core/src/hackkit-context.ts index 8c94e0b5..fb5ea8fb 100644 --- a/packages/core/src/hackkit-context.ts +++ b/packages/core/src/hackkit-context.ts @@ -1,15 +1,15 @@ -import type { HackKitLogger } from "./adapters/logger"; -import type { DatabaseAdapter } from "./database"; -import type { EventTypes } from "./event-types"; -import type { SettingKey, SettingValue } from "./settings"; -import type { UserDataOptions } from "./user-data-options"; -import type { AccessControl, AccessPrincipal } from "./access-control"; -import type { NotificationsApi } from "./notifications"; -import type { AuthId, PermissionKey, Role, User } from "./types"; -import type { HackkitGroup } from "./groups"; +import type { HackKitLogger } from "./adapters/logger.js"; +import type { EntityManager } from "@mikro-orm/core"; +import type { EventTypes } from "./event-types.js"; +import type { SettingKey, SettingValue } from "./settings.js"; +import type { UserDataOptions } from "./user-data-options.js"; +import type { AccessControl, AccessPrincipal } from "./access-control.js"; +import type { NotificationsApi } from "./notifications.js"; +import type { AuthId, PermissionKey, Role, User } from "./types.js"; +import type { HackkitGroup } from "./groups.js"; export type HackkitRuntimeContext = { - db: DatabaseAdapter; + em: EntityManager; now: () => Date; id: () => string; logger: HackKitLogger; @@ -24,10 +24,7 @@ export type HackkitRuntimeContext = { actorAuthId: AuthId, permission: PermissionKey, ) => Promise; - assertCanManageRole: ( - principal: AccessPrincipal, - role: Role, - ) => void; + assertCanManageRole: (principal: AccessPrincipal, role: Role) => void; accessControl: AccessControl; notifications: NotificationsApi; }; diff --git a/packages/core/src/hackkit.ts b/packages/core/src/hackkit.ts index b413accd..b669a1c5 100644 --- a/packages/core/src/hackkit.ts +++ b/packages/core/src/hackkit.ts @@ -1,25 +1,25 @@ -import { - isDatabaseAdapterFactory, - type DatabaseAdapterInput, -} from "./database"; -import { createAccessControl } from "./access-control"; -import { HackKitError, parseInput } from "./errors"; -import type { HackkitRuntimeContext } from "./hackkit-context"; -import { coreModels } from "./models"; -import { CorePermission } from "./permissions"; +import { serialize } from "@mikro-orm/core"; +import { withOperationLock } from "./mikro/operation.js"; +import type { EntityManager } from "@mikro-orm/core"; +import { readUser } from "./mikro/user.js"; +import { createAccessControl } from "./access-control.js"; +import { HackKitError, parseInput } from "./errors.js"; +import type { HackkitRuntimeContext } from "./hackkit-context.js"; +import { coreModels } from "./models.js"; +import { CorePermission } from "./permissions.js"; import { createPluginRegistry, setupPluginApis, type HackKitPlugin, type PluginApiMap, -} from "./plugins"; +} from "./plugins.js"; import { assignRoleSchema, createRoleSchema, deleteRoleSchema, registerHackerSchema, updateRoleSchema, -} from "./schemas"; +} from "./schemas.js"; import type { AuthId, Hacker, @@ -28,29 +28,31 @@ import type { RoleId, User, UserData, -} from "./types"; -import { createUsersApi } from "./functions/users"; -import { createEventsApi } from "./functions/events"; -import { createSettingsApi } from "./functions/settings"; -import { createAdminApi } from "./functions/admin"; -import { createCompetitorRegistrationPolicy } from "./functions/registration-policy"; +} from "./types.js"; +import { createUsersApi } from "./functions/users.js"; +import { createEventsApi } from "./functions/events.js"; +import { createSettingsApi } from "./functions/settings.js"; +import { createAdminApi } from "./functions/admin.js"; +import { createCompetitorRegistrationPolicy } from "./functions/registration-policy.js"; import { createCompleteUserDataSchema, resolveUserDataOptions, type UserDataOptionsInput, -} from "./user-data-options"; -import { resolveEventTypes, type EventTypesInput } from "./event-types"; -import { resolveGroups, type GroupsInput } from "./groups"; -import { createLogger, type HackKitLoggerOptions } from "./adapters/logger"; -import { withDomainLog } from "./domain-log"; -import { createNotificationsApi } from "./notifications"; -import { createRsvpApi } from "./functions/rsvp"; -import { createGroupsApi } from "./functions/groups"; +} from "./user-data-options.js"; +import { resolveEventTypes, type EventTypesInput } from "./event-types.js"; +import { resolveGroups, type GroupsInput } from "./groups.js"; +import { createLogger, type HackKitLoggerOptions } from "./adapters/logger.js"; +import { withDomainLog } from "./domain-log.js"; +import { createNotificationsApi } from "./notifications.js"; +import { createRsvpApi } from "./functions/rsvp.js"; +import { createGroupsApi } from "./functions/groups.js"; -type CreateHackkitOptions< +export type CreateHackkitOptions< TPlugins extends readonly HackKitPlugin[] = readonly HackKitPlugin[], > = { - database: DatabaseAdapterInput; + em: EntityManager; + registry?: import("./plugins.js").HackKitRegistry; + actorAuthId?: string; plugins?: TPlugins; clock?: () => Date; id?: () => string; @@ -68,12 +70,10 @@ export function createHackkit< const TPlugins extends readonly HackKitPlugin[] = [], >(options: CreateHackkitOptions) { const plugins = options.plugins ?? ([] as unknown as TPlugins); - const registry = createPluginRegistry(plugins); + const registry = options.registry ?? createPluginRegistry(plugins); const now = options.clock ?? (() => new Date()); const id = options.id ?? defaultId; - const db = isDatabaseAdapterFactory(options.database) - ? options.database.create({ storage: registry.storage, now, id }) - : options.database; + const em = options.em; const userDataOptions = resolveUserDataOptions(options.userDataOptions); const eventTypes = resolveEventTypes(options.eventTypes); const groups = resolveGroups(options.groups); @@ -81,18 +81,18 @@ export function createHackkit< createCompleteUserDataSchema(userDataOptions); const logger = createLogger(options.logger); const defaultCompetitorRoleId = options.defaultCompetitorRoleId; - const notificationsApi = createNotificationsApi({ db, now }); + const notificationsApi = createNotificationsApi({ em, now }); async function getUserOrThrow(authId: AuthId): Promise { - const user = await db.findOne(coreModels.user, { authId }); + const user = await readUser(em, { authId }); if (!user) throw new HackKitError("NOT_FOUND", "User not found."); return user; } async function getRoleOrThrow(roleId: RoleId): Promise { - const role = await db.findOne(coreModels.role, { id: roleId }); + const role = await em.findOne(coreModels.role, { id: roleId }); if (!role) throw new HackKitError("NOT_FOUND", "Role not found."); - return role; + return serialize(role); } const accessControl = createAccessControl({ @@ -100,14 +100,16 @@ export function createHackkit< getRoleOrThrow, }); const settingsApi = createSettingsApi({ - db, + em, now, logger, settings: registry.settings, requirePermission: accessControl.requirePermission, }); const pluginApis = setupPluginApis(plugins, { - database: db, + em, + actorAuthId: options.actorAuthId, + getUser: (authId) => readUser(em, { authId }), registry, getSettingValue: settingsApi.getValue, notifications: notificationsApi, @@ -115,7 +117,7 @@ export function createHackkit< }); const runtimeContext: HackkitRuntimeContext = { - db, + em, now, id, logger, @@ -138,68 +140,56 @@ export function createHackkit< const groupsApi = createGroupsApi(runtimeContext); async function registerHacker(input: unknown): Promise { - const parsed = parseInput(registerHackerSchema, input); - return withDomainLog( - logger, - "hackers.register", - { targetAuthId: parsed.authId }, - async () => { - await getUserOrThrow(parsed.authId); - const userData = await db.findOne(coreModels.userData, { - authId: parsed.authId, - }); - if (!userData) - throw new HackKitError( - "INVALID_OPERATION", - "User Data must be completed before registering as a Hacker.", - ); - const existing = await db.findOne(coreModels.hacker, { - authId: parsed.authId, - }); - const timestamp = now(); - const value: Hacker = { - ...parsed, - registeredAt: existing?.registeredAt ?? timestamp, - updatedAt: timestamp, - }; + return withOperationLock(em, "registration", async () => { + const parsed = parseInput(registerHackerSchema, input); + return withDomainLog( + logger, + "hackers.register", + { targetAuthId: parsed.authId }, + async () => { + await getUserOrThrow(parsed.authId); + const userData = await em.findOne(coreModels.userData, { + authId: parsed.authId, + }); + if (!userData) + throw new HackKitError( + "INVALID_OPERATION", + "User Data must be completed before registering as a Hacker.", + ); + const existing = await em.findOne(coreModels.hacker, { + authId: parsed.authId, + }); + const timestamp = now(); + const value: Hacker = { + ...parsed, + registeredAt: existing?.registeredAt ?? timestamp, + updatedAt: timestamp, + }; - let hacker: Hacker; - if (!existing) { + if (existing) { + em.assign(existing, value, { ignoreUndefined: true }); + await em.flush(); + return serialize(existing); + } const { requireApproval } = await registrationPolicy.assertCanRegisterNewHacker(); - hacker = await db.insert(coreModels.hacker, value); + const hacker = em.create(coreModels.hacker, value); + const profile = await em.findOneOrFail(coreModels.user, { + authId: parsed.authId, + }); if (defaultCompetitorRoleId) { await getRoleOrThrow(defaultCompetitorRoleId); - await db.update( - coreModels.user, - { authId: parsed.authId }, - { - roleId: defaultCompetitorRoleId, - isApproved: !requireApproval, - updatedAt: timestamp, - }, - ); + profile.roleId = defaultCompetitorRoleId; + profile.isApproved = !requireApproval; } else if (!requireApproval) { - await db.update( - coreModels.user, - { authId: parsed.authId }, - { - isApproved: true, - updatedAt: timestamp, - }, - ); + profile.isApproved = true; } - } else { - const [updated] = await db.update( - coreModels.hacker, - { authId: parsed.authId }, - value, - ); - hacker = updated ?? value; - } - return hacker; - }, - ); + profile.updatedAt = timestamp; + await em.flush(); + return serialize(hacker); + }, + ); + }); } const hackkit = { @@ -224,28 +214,31 @@ export function createHackkit< options: userDataOptions, async getUserData(authId: AuthId): Promise { - return db.findOne(coreModels.userData, { authId }); + const record = await em.findOne(coreModels.userData, { + authId, + }); + return record ? serialize(record) : null; }, async completeUserData(input: unknown): Promise { - const parsed = parseInput(completeUserDataSchema, input); - await getUserOrThrow(parsed.authId); - const existing = await db.findOne(coreModels.userData, { - authId: parsed.authId, + return withOperationLock(em, "user-data", async () => { + const parsed = parseInput(completeUserDataSchema, input); + await getUserOrThrow(parsed.authId); + const existing = await em.findOne(coreModels.userData, { + authId: parsed.authId, + }); + const timestamp = now(); + const value: UserData = { + ...parsed, + completedAt: existing?.completedAt ?? timestamp, + updatedAt: timestamp, + }; + const record = existing + ? em.assign(existing, value, { ignoreUndefined: true }) + : em.create(coreModels.userData, value); + await em.flush(); + return serialize(record); }); - const timestamp = now(); - const value: UserData = { - ...parsed, - completedAt: existing?.completedAt ?? timestamp, - updatedAt: timestamp, - }; - if (!existing) return db.insert(coreModels.userData, value); - const [updated] = await db.update( - coreModels.userData, - { authId: parsed.authId }, - value, - ); - return updated ?? value; }, }, @@ -253,7 +246,8 @@ export function createHackkit< registerHacker, async getHacker(authId: AuthId): Promise { - return db.findOne(coreModels.hacker, { authId }); + const record = await em.findOne(coreModels.hacker, { authId }); + return record ? serialize(record) : null; }, }, @@ -264,131 +258,165 @@ export function createHackkit< input.actorAuthId, CorePermission.RolesView, ); - return db.findMany(coreModels.role, { - orderBy: { field: "position", direction: "asc" }, - }); + return serialize( + await em.find( + coreModels.role, + {}, + { orderBy: { position: "asc" } }, + ), + ); }, async getRole(roleId: RoleId): Promise { - return db.findOne(coreModels.role, { id: roleId }); + const record = await em.findOne(coreModels.role, { + id: roleId, + }); + return record ? serialize(record) : null; }, async createRole(input: unknown): Promise { - const parsed = parseInput(createRoleSchema, input); - const principal = await accessControl.requirePermission( - parsed.actorAuthId, - CorePermission.RolesCreate, - ); - const rolePosition = { - ...principal.role, - position: parsed.position, - }; - accessControl.assertCanManageRole(principal, rolePosition); - accessControl.assertCanGrantPermissions( - principal, - parsed.permissions as PermissionKey[], - ); - const existingName = await db.findOne(coreModels.role, { - name: parsed.name, - }); - if (existingName) - throw new HackKitError( - "CONFLICT", - "Role name already exists.", + return withOperationLock(em, "roles", async () => { + const parsed = parseInput(createRoleSchema, input); + const principal = await accessControl.requirePermission( + parsed.actorAuthId, + CorePermission.RolesCreate, ); - const timestamp = now(); - return db.insert(coreModels.role, { - id: parsed.id ?? id(), - name: parsed.name, - position: parsed.position, - permissions: parsed.permissions as PermissionKey[], - color: parsed.color, - createdAt: timestamp, - updatedAt: timestamp, - }); - }, - - async updateRole(input: unknown): Promise { - const parsed = parseInput(updateRoleSchema, input); - const principal = await accessControl.requirePermission( - parsed.actorAuthId, - CorePermission.RolesUpdate, - ); - const role = await getRoleOrThrow(parsed.roleId); - accessControl.assertCanManageRole(principal, role); - if (parsed.position !== undefined) - accessControl.assertCanManageRole(principal, { - ...role, + const rolePosition = { + ...principal.role, position: parsed.position, - }); - if (parsed.permissions) + }; + accessControl.assertCanManageRole(principal, rolePosition); accessControl.assertCanGrantPermissions( principal, parsed.permissions as PermissionKey[], ); - const [updated] = await db.update( - coreModels.role, - { id: parsed.roleId }, - { + const existingName = await em.findOne(coreModels.role, { name: parsed.name, - position: parsed.position, - permissions: parsed.permissions as - | PermissionKey[] - | undefined, - color: parsed.color, - updatedAt: now(), - }, - ); - if (!updated) - throw new HackKitError("NOT_FOUND", "Role not found."); - return updated; + }); + if (existingName) + throw new HackKitError( + "CONFLICT", + "Role name already exists.", + ); + const timestamp = now(); + { + const created = em.create(coreModels.role, { + id: parsed.id ?? id(), + name: parsed.name, + position: parsed.position, + permissions: parsed.permissions as PermissionKey[], + color: parsed.color, + createdAt: timestamp, + updatedAt: timestamp, + }); + await em.flush(); + return serialize(created); + } + }); }, - async deleteRole(input: unknown): Promise { - const parsed = parseInput(deleteRoleSchema, input); - const principal = await accessControl.requirePermission( - parsed.actorAuthId, - CorePermission.RolesDelete, - ); - const role = await getRoleOrThrow(parsed.roleId); - accessControl.assertCanManageRole(principal, role); - const usersWithRole = await db.findMany(coreModels.user, { - where: { roleId: parsed.roleId }, - limit: 1, + async updateRole(input: unknown): Promise { + return withOperationLock(em, "roles", async () => { + const parsed = parseInput(updateRoleSchema, input); + const principal = await accessControl.requirePermission( + parsed.actorAuthId, + CorePermission.RolesUpdate, + ); + const role = await getRoleOrThrow(parsed.roleId); + accessControl.assertCanManageRole(principal, role); + if (parsed.position !== undefined) + accessControl.assertCanManageRole(principal, { + ...role, + position: parsed.position, + }); + if (parsed.permissions) + accessControl.assertCanGrantPermissions( + principal, + parsed.permissions as PermissionKey[], + ); + const updated = await em.findOne(coreModels.role, { + id: parsed.roleId, + }); + if (updated) { + em.assign( + updated, + { + name: parsed.name, + position: parsed.position, + permissions: parsed.permissions as + | PermissionKey[] + | undefined, + color: parsed.color, + updatedAt: now(), + }, + { ignoreUndefined: true }, + ); + await em.flush(); + } + if (!updated) + throw new HackKitError("NOT_FOUND", "Role not found."); + return serialize(updated); }); - if (usersWithRole.length > 0) - throw new HackKitError( - "INVALID_OPERATION", - "Cannot delete a role assigned to users.", + }, + + async deleteRole(input: unknown): Promise { + return withOperationLock(em, "roles", async () => { + const parsed = parseInput(deleteRoleSchema, input); + const principal = await accessControl.requirePermission( + parsed.actorAuthId, + CorePermission.RolesDelete, + ); + const role = await getRoleOrThrow(parsed.roleId); + accessControl.assertCanManageRole(principal, role); + const usersWithRole = await em.find( + coreModels.user, + { roleId: parsed.roleId }, + { limit: 1 }, ); - await db.delete(coreModels.role, { id: parsed.roleId }); + if (usersWithRole.length > 0) + throw new HackKitError( + "INVALID_OPERATION", + "Cannot delete a role assigned to users.", + ); + await em.nativeDelete(coreModels.role, { + id: parsed.roleId, + }); + }); }, async assignRoleToUser(input: unknown): Promise { - const parsed = parseInput(assignRoleSchema, input); - const principal = await accessControl.requirePermission( - parsed.actorAuthId, - CorePermission.RolesAssign, - ); - const target = await getUserOrThrow(parsed.targetAuthId); - const nextRole = await getRoleOrThrow(parsed.roleId); - accessControl.assertCanManageRole(principal, nextRole); - if (target.roleId) - accessControl.assertCanManageRole( - principal, - await getRoleOrThrow(target.roleId), + return withOperationLock(em, "roles", async () => { + const parsed = parseInput(assignRoleSchema, input); + const principal = await accessControl.requirePermission( + parsed.actorAuthId, + CorePermission.RolesAssign, ); - const [updated] = await db.update( - coreModels.user, - { authId: parsed.targetAuthId }, - { - roleId: parsed.roleId, - updatedAt: now(), - }, - ); - if (!updated) - throw new HackKitError("NOT_FOUND", "User not found."); - return updated; + const target = await getUserOrThrow(parsed.targetAuthId); + const nextRole = await getRoleOrThrow(parsed.roleId); + accessControl.assertCanManageRole(principal, nextRole); + if (target.roleId) + accessControl.assertCanManageRole( + principal, + await getRoleOrThrow(target.roleId), + ); + const updated = await em.findOne(coreModels.user, { + authId: parsed.targetAuthId, + }); + if (updated) { + em.assign( + updated, + { + roleId: parsed.roleId, + updatedAt: now(), + }, + { ignoreUndefined: true }, + ); + await em.flush(); + } + if (!updated) + throw new HackKitError("NOT_FOUND", "User not found."); + return getUserOrThrow(parsed.targetAuthId); + }); }, }, }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 863b5adf..8464417e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,3 @@ -export type { AuthAdapter, AuthIdentity, AuthSession } from "./adapters/auth"; export type { BlobStorageAdapter, BlobStorageAdapterWithView, @@ -6,66 +5,65 @@ export type { BlobUploadTargetInput, BlobViewInput, BlobViewResult, -} from "./adapters/blob"; +} from "./adapters/blob.js"; export { createDefaultLogger, createLogger, logDomain, resolveDefaultLogLevel, -} from "./adapters/logger"; +} from "./adapters/logger.js"; export type { DomainLogContext, HackKitLogger, HackKitLoggerOptions, LogLevel, -} from "./adapters/logger"; -export { createHackkit } from "./hackkit"; -export { createHackKitAuth } from "./mikro/auth"; -export type { HackKitAuthOptions } from "./mikro/auth"; -export { authEntities, AuthUser, AuthSession, AuthAccount, AuthVerification } from "./mikro/auth-entities"; -export { HackKitProfile } from "./mikro/profile"; -export type { HackKit } from "./hackkit"; -export { seedRoles } from "./seed"; -export type { SeedRoleInput, SeedRolesOptions, SeedRolesResult } from "./seed"; +} from "./adapters/logger.js"; +export { createHackKitAuth } from "./mikro/auth.js"; +export type { HackKitAuthOptions } from "./mikro/auth.js"; export { - defineModel, - field, - isDatabaseAdapterFactory, - model, -} from "./database"; + authEntities, + AuthUser, + AuthSession, + AuthAccount, + AuthVerification, +} from "./mikro/auth-entities.js"; +export { HackKitProfile } from "./mikro/profile.js"; +export type { HackKit } from "./hackkit.js"; +export { seedRoles } from "./seed.js"; +export type { + SeedRoleInput, + SeedRolesOptions, + SeedRolesResult, +} from "./seed.js"; export { createCompleteUserDataSchema, defaultUserDataOptions, resolveUserDataOptions, -} from "./user-data-options"; +} from "./user-data-options.js"; export { defaultEventTypes, eventTypeValueSchema, resolveEventTypes, -} from "./event-types"; -export { getEnabledGroups, resolveGroups } from "./groups"; +} from "./event-types.js"; +export { getEnabledGroups, resolveGroups } from "./groups.js"; export { CoreSetting, coreSettings, defineSetting, validateSettingValue, -} from "./settings"; -export { - createInMemoryDatabaseAdapter, - createInMemoryDatabaseAdapterFromStorage, -} from "./adapters/db/memory"; -export { createAccessControl } from "./access-control"; -export type { AccessControl, AccessPrincipal } from "./access-control"; -export type { HackkitRuntimeContext } from "./hackkit-context"; +} from "./settings.js"; +export { createAccessControl } from "./access-control.js"; +export type { AccessControl, AccessPrincipal } from "./access-control.js"; +export type { HackkitRuntimeContext } from "./hackkit-context.js"; export { CoreNotificationKind, coreNotificationPayloadSchemas, createNotificationsApi, -} from "./notifications"; -export { createRsvpApi } from "./functions/rsvp"; -export type { RsvpApi, RsvpSummary } from "./functions/rsvp"; -export { createGroupsApi } from "./functions/groups"; -export type { GroupsApi } from "./functions/groups"; +} from "./notifications.js"; +export { createRsvpApi } from "./functions/rsvp.js"; +export type { RsvpApi, RsvpSummary } from "./functions/rsvp.js"; +export { createGroupsApi } from "./functions/groups.js"; +export type { GroupsApi } from "./functions/groups.js"; export type { CoreNotificationPayloadMap, DeliverPendingNotificationsInput, @@ -79,42 +77,18 @@ export type { NotificationsApi, NotificationsApiContext, QueueNotificationIntentInput, -} from "./notifications"; +} from "./notifications.js"; export type { EventTypeOption, EventTypes, EventTypesInput, -} from "./event-types"; -export type { GroupsInput, HackkitGroup, HackkitGroupInput } from "./groups"; -export type { - AnyField, - DatabaseAdapter, - DatabaseAdapterFactory, - DatabaseAdapterFactoryContext, - DatabaseAdapterInput, - DatabaseSchemaAdapter, - FieldDefinition, - FieldKind, - FieldReference, - FindManyOptions, - GeneratedSchemaFile, - InferInsert, - InferSelect, - Model, - ModelDefinition, - ModelKey, - OrderBy, - PersistentModel, - ReferenceAction, - StorageDefault, - StorageRegistry, - Where, -} from "./database"; +} from "./event-types.js"; +export type { GroupsInput, HackkitGroup, HackkitGroupInput } from "./groups.js"; export type { UserDataOption, UserDataOptions, UserDataOptionsInput, -} from "./user-data-options"; +} from "./user-data-options.js"; export type { BooleanSettingDefinition, HackathonSettingDefinition, @@ -123,18 +97,18 @@ export type { SettingKey, SettingValue, SettingValueType, -} from "./settings"; -export { HackKitError, hackKitErrorCodes } from "./errors"; -export type { HackKitErrorCode } from "./errors"; -export { coreModels } from "./models"; -export { CorePermission, hasPermission, hasSuperAdmin } from "./permissions"; -export { createPluginRegistry, setupPluginApis } from "./plugins"; +} from "./settings.js"; +export { HackKitError, hackKitErrorCodes } from "./errors.js"; +export type { HackKitErrorCode } from "./errors.js"; +export { coreModels } from "./models.js"; +export { CorePermission, hasPermission, hasSuperAdmin } from "./permissions.js"; +export { createPluginRegistry, setupPluginApis } from "./plugins.js"; export type { HackKitPlugin, HackKitPluginContext, HackKitRegistry, PluginApiMap, -} from "./plugins"; +} from "./plugins.js"; export type { AuthId, Event, @@ -153,20 +127,9 @@ export type { AdminUserExportRow, AdminUserRecord, PublicUserProfile, - NewHacker, - NewRsvp, - NewRole, - NewUser, - NewUserBan, - NewUserData, - NewEvent, - NewEventScan, - NewHackathonSetting, - NewNotificationDeliveryAttempt, - NewNotificationIntent, NotificationDeliveryAttempt, NotificationIntent, -} from "./types"; +} from "./types.js"; export { adminCancelRsvpSchema, adminPromoteRsvpSchema, @@ -183,7 +146,6 @@ export { createRoleSchema, deleteEventSchema, deleteRoleSchema, - ensureUserSchema, getEventSchema, hackTagSchema, listEventScansSchema, @@ -196,5 +158,16 @@ export { updateEventSchemaFactory, updateRoleSchema, updateUserProfileSchema, -} from "./schemas"; -export type { CompleteUserDataInput } from "./schemas"; +} from "./schemas.js"; +export type { CompleteUserDataInput } from "./schemas.js"; + +export { initializeHackkit, createOrmOptions } from "./mikro/runtime.js"; +export type { + InitializeHackkitOptions, + HackKitDatabaseOptions, + HackKitDriver, + HackKitScope, + HackKitRuntime, +} from "./mikro/runtime.js"; + +export { withOperationLock } from "./mikro/operation.js"; diff --git a/packages/core/src/mikro/auth-entities.test.ts b/packages/core/src/mikro/auth-entities.test.ts index 454fe3e1..40d74d43 100644 --- a/packages/core/src/mikro/auth-entities.test.ts +++ b/packages/core/src/mikro/auth-entities.test.ts @@ -2,13 +2,29 @@ import { describe, expect, it } from "vitest"; import { MikroORM, RequestContext } from "@mikro-orm/core"; import { SqliteDriver } from "@mikro-orm/sqlite"; import { LibSqlDriver } from "@mikro-orm/libsql"; -import { AuthUser, authEntities } from "./auth-entities"; -import { createHackKitAuth } from "./auth"; -import { HackKitProfile } from "./profile"; +import { AuthUser, authEntities } from "./auth-entities.js"; +import { createHackKitAuth } from "./auth.js"; +import { HackKitProfile } from "./profile.js"; describe.each([ - ["SQLite", () => MikroORM.init({ driver: SqliteDriver, dbName: ":memory:", entities: [...authEntities, HackKitProfile] })], - ["local libSQL", () => MikroORM.init({ driver: LibSqlDriver, dbName: ":memory:", entities: [...authEntities, HackKitProfile] })], + [ + "SQLite", + () => + MikroORM.init({ + driver: SqliteDriver, + dbName: ":memory:", + entities: [...authEntities, HackKitProfile], + }), + ], + [ + "local libSQL", + () => + MikroORM.init({ + driver: LibSqlDriver, + dbName: ":memory:", + entities: [...authEntities, HackKitProfile], + }), + ], ])("Better Auth MikroORM integration on %s", (_name, open) => { it("persists signup and session with a request-scoped manager", async () => { const orm = await open(); @@ -39,27 +55,48 @@ describe.each([ const users = await orm.em.fork().find(AuthUser, {}); expect(users).toHaveLength(1); const em = orm.em.fork(); - const profile = em.create(HackKitProfile, { - user: users[0], - firstName: "Example", - lastName: "User", - hackTag: "example", + const profile = await em.findOneOrFail(HackKitProfile, { + authId: users[0].id, }); + profile.hackTag = "example"; await em.persist(profile).flush(); - expect((await em.fork().findOneOrFail(HackKitProfile, { hackTag: "example" })).skills).toEqual([]); + expect( + ( + await em + .fork() + .findOneOrFail(HackKitProfile, { hackTag: "example" }) + ).skills, + ).toEqual([]); const duplicateManager = em.fork(); - await expect(duplicateManager.persist( - duplicateManager.create(HackKitProfile, { - user: users[0], firstName: "Duplicate", lastName: "User", + await expect( + duplicateManager + .persist( + duplicateManager.create(HackKitProfile, { + authId: users[0].id, + firstName: "Duplicate", + lastName: "User", + }), + ) + .flush(), + ).rejects.toThrow(); + await expect( + em.transactional(async (transaction) => { + const value = await transaction.findOneOrFail( + HackKitProfile, + { hackTag: "example" }, + ); + value.bio = "rolled back"; + await transaction.flush(); + throw new Error("abort"); }), - ).flush()).rejects.toThrow(); - await expect(em.transactional(async (transaction) => { - const value = await transaction.findOneOrFail(HackKitProfile, { hackTag: "example" }); - value.bio = "rolled back"; - await transaction.flush(); - throw new Error("abort"); - })).rejects.toThrow("abort"); - expect((await em.fork().findOneOrFail(HackKitProfile, { hackTag: "example" })).bio).toBeNull(); + ).rejects.toThrow("abort"); + expect( + ( + await em + .fork() + .findOneOrFail(HackKitProfile, { hackTag: "example" }) + ).bio, + ).toBeNull(); expect(await orm.schema.getUpdateSchemaSQL()).toBe(""); } finally { await orm.close(); diff --git a/packages/core/src/mikro/auth-entities.ts b/packages/core/src/mikro/auth-entities.ts index 101d81a9..8242d884 100644 --- a/packages/core/src/mikro/auth-entities.ts +++ b/packages/core/src/mikro/auth-entities.ts @@ -10,7 +10,7 @@ export const AuthUser = defineEntity({ name: p.string(), email: p.string().unique(), emailVerified: p.boolean().default(false), - image: p.string().nullable(), + image: p.text().nullable(), createdAt: p.datetime(), updatedAt: p.datetime(), }, @@ -26,8 +26,14 @@ export const AuthSession = defineEntity({ createdAt: p.datetime(), updatedAt: p.datetime(), ipAddress: p.string().nullable(), - userAgent: p.string().nullable(), - userId: p.string().index(), + userAgent: p.text().nullable(), + userId: () => + p + .manyToOne(AuthUser) + .mapToPk() + .fieldName("user_id") + .index() + .deleteRule("cascade"), }, }); @@ -38,13 +44,19 @@ export const AuthAccount = defineEntity({ id: p.string().primary(), accountId: p.string(), providerId: p.string(), - userId: p.string().index(), - accessToken: p.string().nullable(), - refreshToken: p.string().nullable(), - idToken: p.string().nullable(), + userId: () => + p + .manyToOne(AuthUser) + .mapToPk() + .fieldName("user_id") + .index() + .deleteRule("cascade"), + accessToken: p.text().nullable(), + refreshToken: p.text().nullable(), + idToken: p.text().nullable(), accessTokenExpiresAt: p.datetime().nullable(), refreshTokenExpiresAt: p.datetime().nullable(), - scope: p.string().nullable(), + scope: p.text().nullable(), password: p.string().nullable(), createdAt: p.datetime(), updatedAt: p.datetime(), @@ -57,11 +69,16 @@ export const AuthVerification = defineEntity({ properties: { id: p.string().primary(), identifier: p.string().index(), - value: p.string(), + value: p.text(), expiresAt: p.datetime(), createdAt: p.datetime().nullable(), updatedAt: p.datetime().nullable(), }, }); -export const authEntities = [AuthUser, AuthSession, AuthAccount, AuthVerification]; +export const authEntities = [ + AuthUser, + AuthSession, + AuthAccount, + AuthVerification, +]; diff --git a/packages/core/src/mikro/auth-lifecycle.test.ts b/packages/core/src/mikro/auth-lifecycle.test.ts new file mode 100644 index 00000000..6a370947 --- /dev/null +++ b/packages/core/src/mikro/auth-lifecycle.test.ts @@ -0,0 +1,112 @@ +import { MikroORM } from "@mikro-orm/core"; +import { SqliteDriver } from "@mikro-orm/sqlite"; +import { describe, expect, it } from "vitest"; +import { createHackKitAuth } from "./auth.js"; +import { + authEntities, + AuthUser, + AuthSession, + AuthAccount, +} from "./auth-entities.js"; +import { HackKitProfile } from "./profile.js"; + +describe("Core auth lifecycle", () => { + it("rolls back the identity and profile if signup fails", async () => { + const orm = await MikroORM.init({ + driver: SqliteDriver, + dbName: ":memory:", + entities: [...authEntities, HackKitProfile], + }); + try { + await orm.schema.create(); + let profileExistsBeforeAccount = false; + const auth = createHackKitAuth(orm, { + secret: "local-test-secret-with-at-least-32-characters", + baseURL: "http://localhost:3000", + emailAndPassword: { enabled: true }, + databaseHooks: { + account: { + create: { + before: async () => { + profileExistsBeforeAccount = + (await orm.em.count(HackKitProfile)) === 1; + throw new Error("Reject account"); + }, + }, + }, + }, + }); + await expect( + auth.api.signUpEmail({ + body: { + email: "rejected@example.com", + name: "Rejected User", + password: "a sufficiently long password", + }, + asResponse: true, + }), + ).rejects.toThrow("Reject account"); + expect(profileExistsBeforeAccount).toBe(true); + expect(await orm.em.fork().count(AuthUser)).toBe(0); + expect(await orm.em.fork().count(HackKitProfile)).toBe(0); + } finally { + await orm.close(); + } + }); + + it("creates a profile on signup and preserves edits when reading sessions", async () => { + const orm = await MikroORM.init({ + driver: SqliteDriver, + dbName: ":memory:", + entities: [...authEntities, HackKitProfile], + }); + try { + await orm.schema.create(); + const auth = createHackKitAuth(orm, { + secret: "local-test-secret-with-at-least-32-characters", + baseURL: "http://localhost:3000", + emailAndPassword: { enabled: true }, + }); + const signup = await auth.api.signUpEmail({ + body: { + email: "josh@example.com", + name: "Josh Silva", + password: "a sufficiently long password", + }, + asResponse: true, + }); + expect(signup.status).toBe(200); + const headers = new Headers({ + cookie: signup.headers.get("set-cookie") ?? "", + }); + const session = await auth.api.getSession({ headers }); + expect(session?.user.email).toBe("josh@example.com"); + const em = orm.em.fork(); + const profile = await em.findOneOrFail(HackKitProfile, { + authId: session!.user.id, + }); + expect(profile.firstName).toBe("Josh"); + expect(profile.lastName).toBe("Silva"); + profile.firstName = "Joshua"; + await em.flush(); + await auth.api.getSession({ headers }); + expect( + ( + await em.fork().findOneOrFail(HackKitProfile, { + authId: session!.user.id, + }) + ).firstName, + ).toBe("Joshua"); + await em + .remove( + await em.findOneOrFail(AuthUser, { id: session!.user.id }), + ) + .flush(); + expect(await em.fork().count(HackKitProfile)).toBe(0); + expect(await em.fork().count(AuthSession)).toBe(0); + expect(await em.fork().count(AuthAccount)).toBe(0); + } finally { + await orm.close(); + } + }); +}); diff --git a/packages/core/src/mikro/auth-schema.test.ts b/packages/core/src/mikro/auth-schema.test.ts new file mode 100644 index 00000000..6629b9af --- /dev/null +++ b/packages/core/src/mikro/auth-schema.test.ts @@ -0,0 +1,34 @@ +import { expect, it } from "vitest"; +import { createTestHackkit } from "../testing.js"; +import { createHackKitAuth } from "./auth.js"; + +it("rejects auth options that need undeclared columns before serving requests", async () => { + const { runtime } = await createTestHackkit(); + expect(() => + createHackKitAuth(runtime.orm, { + secret: "test-secret-that-is-at-least-32-characters", + user: { + additionalFields: { + department: { type: "string", required: false }, + }, + }, + }), + ).toThrow(/department/); +}); + +it("rejects an auth plugin whose table is not in the entity registry", async () => { + const { runtime } = await createTestHackkit(); + expect(() => + createHackKitAuth(runtime.orm, { + secret: "test-secret-that-is-at-least-32-characters", + plugins: [ + { + id: "test-plugin", + schema: { + membership: { fields: { label: { type: "string" } } }, + }, + }, + ], + }), + ).toThrow(/membership/); +}); diff --git a/packages/core/src/mikro/auth.ts b/packages/core/src/mikro/auth.ts index 93a68ff9..1c9d52fc 100644 --- a/packages/core/src/mikro/auth.ts +++ b/packages/core/src/mikro/auth.ts @@ -1,11 +1,71 @@ -import type { MikroORM } from "@mikro-orm/core"; +import type { EntityMetadata, MikroORM } from "@mikro-orm/core"; import { mikroOrmAdapter } from "@a77ay/better-auth-mikro-orm"; import { betterAuth, type BetterAuthOptions } from "better-auth"; +import { getAuthTables } from "better-auth/db"; +import { HackKitProfile } from "./profile.js"; +import { AuthUser } from "./auth-entities.js"; +import type { FlushEventArgs } from "@mikro-orm/core"; export type HackKitAuthOptions = Omit; +/** Fail startup and migration generation when auth needs an undeclared schema. */ +export function assertAuthEntitySchema( + entities: readonly Pick< + EntityMetadata, + "tableName" | "className" | "properties" + >[], + options: HackKitAuthOptions, +): void { + for (const table of Object.values(getAuthTables(options))) { + const entity = entities.find( + (candidate) => + candidate.tableName === table.modelName || + candidate.className === table.modelName, + ); + if (!entity) + throw new Error( + `Better Auth model '${table.modelName}' needs a registered native MikroORM entity.`, + ); + for (const [name, field] of Object.entries(table.fields)) { + const property = field.fieldName ?? name; + if (!(property in entity.properties)) + throw new Error( + `Better Auth field '${table.modelName}.${property}' needs a native MikroORM property and a reviewed migration.`, + ); + } + } +} + +const profileSubscriber = { + beforeFlush({ em, uow }: FlushEventArgs) { + for (const user of uow.getPersistStack()) { + if ( + !(user instanceof AuthUser.class) || + uow.getOriginalEntityData(user) + ) + continue; + const [firstName = "", ...lastName] = user.name.trim().split(/\s+/); + em.create(HackKitProfile, { + authId: user.id, + firstName, + lastName: lastName.join(" "), + profilePhotoUrl: user.image ?? null, + }); + } + }, +}; + /** The application supplies credentials and providers; Core owns the database adapter. */ -export function createHackKitAuth(orm: MikroORM, options: HackKitAuthOptions) { +export function createHackKitAuth( + orm: MikroORM, + options: TOptions, +) { + assertAuthEntitySchema([...orm.getMetadata().getAll().values()], options); + // Better Auth's create.after hooks run after commit. Create the profile in the + // same MikroORM flush instead, so identity and profile always commit together. + const events = orm.em.getEventManager(); + if (!events.getSubscribers().has(profileSubscriber)) + events.registerSubscriber(profileSubscriber); return betterAuth({ ...options, database: mikroOrmAdapter(orm), diff --git a/packages/core/src/mikro/domain-transactions.test.ts b/packages/core/src/mikro/domain-transactions.test.ts new file mode 100644 index 00000000..a2508533 --- /dev/null +++ b/packages/core/src/mikro/domain-transactions.test.ts @@ -0,0 +1,75 @@ +import { expect, it } from "vitest"; +import { createTestHackkit, createTestUser } from "../testing.js"; +import { CoreSetting } from "../settings.js"; +import { seedTestOwner } from "../__tests__/seed-test-owner.js"; + +const registration = { + university: "UTSA", + major: "Computer Science", + levelOfStudy: "undergraduate", + hackathonsAttended: 0, + softwareExperience: "beginner", +}; +async function prepareParticipant( + hackkit: Awaited>, + authId: string, +) { + await createTestUser(hackkit, { + authId, + email: `${authId}@example.com`, + firstName: "Test", + lastName: "Hacker", + }); + await hackkit.userData.completeUserData({ + authId, + age: 21, + gender: "prefer_not_to_answer", + race: "prefer_not_to_answer", + ethnicity: "prefer_not_to_answer", + shirtSize: "m", + dietaryRestrictions: ["none"], + hasAcceptedMLHCodeOfConduct: true, + hasSharedDataWithMLH: true, + isEmailable: false, + }); +} + +it("rolls registration back when assigning the configured role fails", async () => { + const hackkit = await createTestHackkit({ + defaultCompetitorRoleId: "missing-role", + }); + await prepareParticipant(hackkit, "participant"); + await expect( + hackkit.hackers.registerHacker({ + ...registration, + authId: "participant", + }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + const fresh = hackkit.runtime.createScope().hackkit; + await expect(fresh.hackers.getHacker("participant")).resolves.toBeNull(); + expect((await fresh.users.getUser("participant"))?.isApproved).toBe(false); +}); + +it("admits only one concurrent registration into the final place", async () => { + const hackkit = await createTestHackkit(); + await seedTestOwner(hackkit); + await hackkit.settings.set({ + actorAuthId: "owner-auth", + key: CoreSetting.MaximumRegistrations, + value: 1, + }); + await prepareParticipant(hackkit, "one"); + await prepareParticipant(hackkit, "two"); + const results = await Promise.allSettled( + ["one", "two"].map((authId) => + hackkit.runtime + .createScope() + .hackkit.hackers.registerHacker({ ...registration, authId }), + ), + ); + expect( + results.filter((result) => result.status === "fulfilled"), + ).toHaveLength(1); + const rejected = results.find((result) => result.status === "rejected"); + expect(rejected).toMatchObject({ reason: { code: "INVALID_OPERATION" } }); +}); diff --git a/packages/core/src/mikro/entity-registry.test.ts b/packages/core/src/mikro/entity-registry.test.ts new file mode 100644 index 00000000..96e344ef --- /dev/null +++ b/packages/core/src/mikro/entity-registry.test.ts @@ -0,0 +1,48 @@ +import { defineEntity, p } from "@mikro-orm/core"; +import { expect, it } from "vitest"; +import { createPluginRegistry } from "../plugins.js"; + +function entity(name: string, tableName: string) { + return defineEntity({ + name, + tableName, + properties: { id: p.string().primary() }, + }); +} + +it("collects plugin and app entities once alongside Core and auth", () => { + const pluginEntity = entity("ExampleEntry", "example_entry"); + const appEntity = entity("AppEntry", "app_entry"); + const registry = createPluginRegistry( + [{ id: "example", entities: [pluginEntity] }], + [appEntity], + ); + expect(registry.entities).toContain(pluginEntity); + expect(registry.entities).toContain(appEntity); + expect( + registry.entities.filter((item) => item.meta.tableName === "user"), + ).toHaveLength(1); + expect( + registry.entities.filter((item) => item.meta.tableName === "core_user"), + ).toHaveLength(1); +}); + +it("rejects a plugin attempting to use another namespace", () => { + expect(() => + createPluginRegistry([ + { id: "example", entities: [entity("Intruder", "core_intruder")] }, + ]), + ).toThrow("example_"); +}); + +it("rejects duplicate table or entity names before ORM initialization", () => { + expect(() => + createPluginRegistry([], [entity("DuplicateAuth", "user")]), + ).toThrow("Duplicate entity name or table"); + expect(() => + createPluginRegistry( + [], + [entity("Duplicate", "app_one"), entity("Duplicate", "app_two")], + ), + ).toThrow("Duplicate entity name or table"); +}); diff --git a/packages/core/src/mikro/operation.ts b/packages/core/src/mikro/operation.ts new file mode 100644 index 00000000..e0bf5777 --- /dev/null +++ b/packages/core/src/mikro/operation.ts @@ -0,0 +1,46 @@ +import { + TransactionPropagation, + UniqueConstraintViolationException, + type EntityManager, +} from "@mikro-orm/core"; +import { CoreOperationLock } from "../models.js"; +import { HackKitError } from "../errors.js"; + +/** Serialize a read/check/write decision across connections, including SQLite. + * The upsert writes before any reads, acquiring the database's write/row lock. + * A fresh token forces a write even when the lock row is already managed. + * Callbacks contain database work only; deliver external effects after commit. + */ +export async function withOperationLock( + em: EntityManager, + key: string, + operation: () => Promise, +): Promise { + const ownsTransaction = !em.isInTransaction(); + try { + return await em.transactional( + async (transaction) => { + await transaction.upsert(CoreOperationLock, { + key, + token: crypto.randomUUID(), + }); + await transaction.flush(); + return operation(); + }, + { clear: true, propagation: TransactionPropagation.REQUIRED }, + ); + } catch (error) { + if (error instanceof UniqueConstraintViolationException) { + throw new HackKitError( + "CONFLICT", + "A record with these unique values already exists.", + { cause: error }, + ); + } + throw error; + } finally { + // A completed operation is a unit-of-work boundary. Reload subsequent reads + // from the database, and never retain entities from a rolled-back operation. + if (ownsTransaction) em.clear(); + } +} diff --git a/packages/core/src/mikro/profile.ts b/packages/core/src/mikro/profile.ts index 18fe1d06..1fd9a2fa 100644 --- a/packages/core/src/mikro/profile.ts +++ b/packages/core/src/mikro/profile.ts @@ -1,23 +1 @@ -import { defineEntity, p } from "@mikro-orm/core"; -import { AuthUser } from "./auth-entities"; - -export const HackKitProfile = defineEntity({ - name: "HackKitProfile", - tableName: "core_user", - properties: { - user: () => p.oneToOne(AuthUser).owner().primary().deleteRule("cascade"), - firstName: p.string(), - lastName: p.string(), - profilePhotoUrl: p.string().nullable(), - hackTag: p.string().nullable().unique(), - bio: p.string().nullable(), - pronouns: p.string().nullable(), - skills: p.json().default([]), - isProfileSearchable: p.boolean().default(true), - discordDisplayHandle: p.string().nullable(), - isApproved: p.boolean().default(false), - checkedInAt: p.datetime().nullable(), - createdAt: p.datetime().onCreate(() => new Date()), - updatedAt: p.datetime().onCreate(() => new Date()).onUpdate(() => new Date()), - }, -}); +export { HackKitProfile } from "../models.js"; diff --git a/packages/core/src/mikro/runtime.test.ts b/packages/core/src/mikro/runtime.test.ts new file mode 100644 index 00000000..8f4952db --- /dev/null +++ b/packages/core/src/mikro/runtime.test.ts @@ -0,0 +1,44 @@ +import { SqliteDriver } from "@mikro-orm/sqlite"; +import { expect, it } from "vitest"; +import { initializeHackkit } from "./runtime.js"; + +it("shares auth and connections while isolating domain execution scopes", async () => { + const runtime = await initializeHackkit({ + database: { driver: SqliteDriver, dbName: ":memory:" }, + auth: { + secret: "local-test-secret-with-at-least-32-characters", + baseURL: "http://localhost:3000", + emailAndPassword: { enabled: true }, + }, + }); + try { + await runtime.orm.schema.create(); + const signedUp = await runtime.auth.api.signUpEmail({ + body: { + email: "participant@example.com", + name: "Participant One", + password: "a sufficiently long password", + }, + }); + const first = runtime.createScope(); + const second = runtime.createScope(); + expect(first.em).not.toBe(second.em); + await first.hackkit.users.claimHackTag({ + authId: signedUp.user.id, + hackTag: "participant", + }); + await first.hackkit.users.updateProfile({ + authId: signedUp.user.id, + bio: "Building useful things", + }); + const profile = await second.hackkit.users.getUser(signedUp.user.id); + expect(profile).toMatchObject({ + email: "participant@example.com", + hackTag: "participant", + bio: "Building useful things", + }); + expect(Object.getPrototypeOf(profile)).toBe(Object.prototype); + } finally { + await runtime.orm.close(); + } +}); diff --git a/packages/core/src/mikro/runtime.ts b/packages/core/src/mikro/runtime.ts new file mode 100644 index 00000000..1c8cb323 --- /dev/null +++ b/packages/core/src/mikro/runtime.ts @@ -0,0 +1,123 @@ +import { + MikroORM, + RequestContext, + type Options, + type EntitySchema, + type EntityManager, +} from "@mikro-orm/core"; +import { createLogger } from "../adapters/logger.js"; +import type { SqliteDriver } from "@mikro-orm/sqlite"; +import type { LibSqlDriver } from "@mikro-orm/libsql"; +import type { MySqlDriver } from "@mikro-orm/mysql"; +import type { PostgreSqlDriver } from "@mikro-orm/postgresql"; +import { Migrator } from "@mikro-orm/migrations"; +import { createHackkit, type CreateHackkitOptions } from "../hackkit.js"; +import { + createPluginRegistry, + type HackKitPlugin, + type HackKitRegistry, +} from "../plugins.js"; +import { + assertAuthEntitySchema, + createHackKitAuth, + type HackKitAuthOptions, +} from "./auth.js"; + +export type HackKitDriver = + | SqliteDriver + | LibSqlDriver + | MySqlDriver + | PostgreSqlDriver; +export type HackKitDatabaseOptions = Omit< + Partial>, + "entities" | "entitiesTs" +> & { driver: NonNullable["driver"]> }; +export type InitializeHackkitOptions< + TPlugins extends readonly HackKitPlugin[] = readonly HackKitPlugin[], +> = Omit, "em" | "registry" | "actorAuthId"> & { + database: HackKitDatabaseOptions; + auth: HackKitAuthOptions; + entities?: readonly EntitySchema[]; +}; + +export type HackKitScope< + TPlugins extends readonly HackKitPlugin[] = readonly HackKitPlugin[], +> = { + em: EntityManager; + hackkit: ReturnType>; +}; + +export type HackKitRuntime< + TPlugins extends readonly HackKitPlugin[] = readonly HackKitPlugin[], +> = { + orm: MikroORM; + auth: ReturnType; + registry: HackKitRegistry; + createScope(actorAuthId?: string): HackKitScope; + run( + operation: (scope: HackKitScope) => Promise, + actorAuthId?: string, + ): Promise; +}; + +/** Runtime and CLI migrations discover exactly the same entity collection. */ +export function createOrmOptions( + options: Pick< + InitializeHackkitOptions, + "database" | "plugins" | "entities" | "auth" + >, +): Partial> { + const registry = createPluginRegistry(options.plugins, options.entities); + assertAuthEntitySchema( + registry.entities.map((entity) => entity.meta), + options.auth, + ); + return { + ...options.database, + entities: registry.entities, + extensions: [ + ...new Set([...(options.database.extensions ?? []), Migrator]), + ], + }; +} + +/** Initialize shared connections and authentication once, then fork per execution. */ +export async function initializeHackkit< + const TPlugins extends readonly HackKitPlugin[] = [], +>( + options: InitializeHackkitOptions, +): Promise> { + const registry = createPluginRegistry(options.plugins, options.entities); + const orm = await MikroORM.init(createOrmOptions(options)); + let auth: ReturnType; + try { + const logger = createLogger(options.logger); + auth = createHackKitAuth(orm, { + ...options.auth, + logger: options.auth.logger ?? { + disabled: logger.disabled, + level: logger.level, + log: (level, message, ...args) => + logger.log(level, message, ...args), + }, + }); + } catch (error) { + await orm.close(); + throw error; + } + function createScope(actorAuthId?: string) { + const em = orm.em.fork({ useContext: true }); + return { + em, + hackkit: createHackkit({ ...options, em, registry, actorAuthId }), + }; + } + function run( + operation: (scope: ReturnType) => Promise, + actorAuthId?: string, + ) { + const scope = createScope(actorAuthId); + return RequestContext.create(scope.em, () => operation(scope)); + } + return { orm, auth, registry, createScope, run }; +} diff --git a/packages/core/src/mikro/user.ts b/packages/core/src/mikro/user.ts new file mode 100644 index 00000000..1079b40f --- /dev/null +++ b/packages/core/src/mikro/user.ts @@ -0,0 +1,26 @@ +import { + wrap, + type EntityManager, + type FilterQuery, + type InferEntity, +} from "@mikro-orm/core"; +import { AuthUser } from "./auth-entities.js"; +import { HackKitProfile } from "../models.js"; +import type { User } from "../types.js"; + +/** Domain profile data uses Better Auth's current email without copying it. */ +export async function toUser( + em: EntityManager, + profile: InferEntity, +): Promise { + const identity = await em.findOneOrFail(AuthUser, { id: profile.authId }); + return { ...wrap(profile).toObject(), email: identity.email }; +} + +export async function readUser( + em: EntityManager, + where: FilterQuery>, +): Promise { + const profile = await em.findOne(HackKitProfile, where); + return profile ? toUser(em, profile) : null; +} diff --git a/packages/core/src/models.ts b/packages/core/src/models.ts index 61e9c355..82cb750a 100644 --- a/packages/core/src/models.ts +++ b/packages/core/src/models.ts @@ -1,208 +1,388 @@ -import { defineModel, field } from "./database"; +import { defineEntity, p } from "@mikro-orm/core"; +import { AuthUser } from "./mikro/auth-entities.js"; + +export const CoreRole = defineEntity({ + name: "CoreRole", + tableName: "core_role", + properties: { + id: p + .string() + .primary() + .onCreate(() => crypto.randomUUID()), + name: p.string().unique(), + position: p.integer(), + permissions: p.json<`${string}.${string}`[]>().default([]), + color: p.string().nullable(), + createdAt: p.datetime().onCreate(() => new Date()), + updatedAt: p + .datetime() + .onCreate(() => new Date()) + .onUpdate(() => new Date()), + }, + indexes: [{ properties: ["position"] }], +}); + +export const HackKitProfile = defineEntity({ + name: "HackKitProfile", + tableName: "core_user", + properties: { + authId: () => + p + .oneToOne(AuthUser) + .owner() + .mapToPk() + .fieldName("auth_id") + .primary() + .deleteRule("cascade"), + firstName: p.string(), + lastName: p.string(), + profilePhotoUrl: p.text().nullable(), + hackTag: p.string().nullable().unique(), + bio: p.text().nullable(), + pronouns: p.string().nullable(), + skills: p.json().default([]), + isProfileSearchable: p.boolean().default(true), + discordDisplayHandle: p.string().nullable(), + roleId: () => + p + .manyToOne(CoreRole) + .mapToPk() + .fieldName("role_id") + .nullable() + .deleteRule("set null"), + isApproved: p.boolean().default(false), + checkedInAt: p.datetime().nullable(), + createdAt: p.datetime().onCreate(() => new Date()), + updatedAt: p + .datetime() + .onCreate(() => new Date()) + .onUpdate(() => new Date()), + }, + indexes: [ + { properties: ["hackTag"] }, + { properties: ["roleId"] }, + { properties: ["createdAt"] }, + ], +}); + +export const CoreUserData = defineEntity({ + name: "CoreUserData", + tableName: "core_user_data", + properties: { + authId: () => + p + .manyToOne(HackKitProfile) + .mapToPk() + .fieldName("auth_id") + .primary() + .deleteRule("cascade"), + age: p.integer(), + gender: p.string(), + race: p.string(), + ethnicity: p.string(), + shirtSize: p.string(), + dietaryRestrictions: p.json().default([]), + accommodationNote: p.text().nullable(), + phoneNumber: p.string().nullable(), + countryOfResidence: p.string().nullable(), + hasAcceptedMLHCodeOfConduct: p.boolean(), + hasSharedDataWithMLH: p.boolean(), + isEmailable: p.boolean(), + completedAt: p.datetime().onCreate(() => new Date()), + updatedAt: p + .datetime() + .onCreate(() => new Date()) + .onUpdate(() => new Date()), + }, +}); + +export const CoreHacker = defineEntity({ + name: "CoreHacker", + tableName: "core_hacker", + properties: { + authId: () => + p + .manyToOne(HackKitProfile) + .mapToPk() + .fieldName("auth_id") + .primary() + .deleteRule("cascade"), + university: p.string(), + major: p.string(), + schoolId: p.string().nullable(), + levelOfStudy: p.string(), + hackathonsAttended: p.integer(), + softwareExperience: p.string(), + heardFrom: p.string().nullable(), + githubUrl: p.text().nullable(), + linkedInUrl: p.text().nullable(), + personalWebsiteUrl: p.text().nullable(), + resumeUrl: p.text().nullable(), + group: p.string().nullable(), + registeredAt: p.datetime().onCreate(() => new Date()), + updatedAt: p + .datetime() + .onCreate(() => new Date()) + .onUpdate(() => new Date()), + }, + indexes: [{ properties: ["registeredAt"] }], +}); + +export const CoreRsvp = defineEntity({ + name: "CoreRsvp", + tableName: "core_rsvp", + properties: { + authId: () => + p + .manyToOne(HackKitProfile) + .mapToPk() + .fieldName("auth_id") + .primary() + .deleteRule("cascade"), + status: p.enum(["confirmed", "waitlisted", "cancelled"] as const), + waitlistPosition: p.integer().nullable(), + createdAt: p.datetime().onCreate(() => new Date()), + updatedAt: p + .datetime() + .onCreate(() => new Date()) + .onUpdate(() => new Date()), + confirmedAt: p.datetime().nullable(), + waitlistedAt: p.datetime().nullable(), + cancelledAt: p.datetime().nullable(), + cancelledByAuthId: () => + p + .manyToOne(HackKitProfile) + .mapToPk() + .fieldName("cancelled_by_auth_id") + .nullable() + .deleteRule("set null"), + promotedAt: p.datetime().nullable(), + promotedByAuthId: () => + p + .manyToOne(HackKitProfile) + .mapToPk() + .fieldName("promoted_by_auth_id") + .nullable() + .deleteRule("set null"), + }, + indexes: [ + { properties: ["status"] }, + { properties: ["waitlistPosition"] }, + { properties: ["createdAt"] }, + { properties: ["updatedAt"] }, + ], +}); + +export const CoreUserBan = defineEntity({ + name: "CoreUserBan", + tableName: "core_user_ban", + properties: { + authId: () => + p + .manyToOne(HackKitProfile) + .mapToPk() + .fieldName("auth_id") + .primary() + .deleteRule("cascade"), + reason: p.string().nullable(), + bannedByAuthId: () => + p + .manyToOne(HackKitProfile) + .mapToPk() + .fieldName("banned_by_auth_id"), + createdAt: p.datetime().onCreate(() => new Date()), + }, +}); + +export const CoreEvent = defineEntity({ + name: "CoreEvent", + tableName: "core_event", + properties: { + id: p + .string() + .primary() + .onCreate(() => crypto.randomUUID()), + title: p.string(), + startTime: p.datetime(), + endTime: p.datetime(), + location: p.string().default("TBD"), + description: p.text(), + type: p.string(), + host: p.string().nullable(), + hidden: p.boolean().default(false), + createdAt: p.datetime().onCreate(() => new Date()), + updatedAt: p + .datetime() + .onCreate(() => new Date()) + .onUpdate(() => new Date()), + }, + indexes: [ + { properties: ["startTime"] }, + { properties: ["type"] }, + { properties: ["hidden"] }, + ], +}); + +export const CoreSetting = defineEntity({ + name: "CoreSetting", + tableName: "core_setting", + properties: { + key: p.string().primary(), + value: p.json(), + createdAt: p.datetime().onCreate(() => new Date()), + createdByAuthId: () => + p + .manyToOne(HackKitProfile) + .mapToPk() + .fieldName("created_by_auth_id") + .nullable() + .deleteRule("set null"), + updatedAt: p + .datetime() + .onCreate(() => new Date()) + .onUpdate(() => new Date()), + updatedByAuthId: () => + p + .manyToOne(HackKitProfile) + .mapToPk() + .fieldName("updated_by_auth_id") + .nullable() + .deleteRule("set null"), + }, + indexes: [ + { properties: ["updatedAt"] }, + { properties: ["updatedByAuthId"] }, + ], +}); + +export const CoreEventScan = defineEntity({ + name: "CoreEventScan", + tableName: "core_event_scan", + properties: { + id: p + .string() + .primary() + .onCreate(() => crypto.randomUUID()), + eventId: () => + p + .manyToOne(CoreEvent) + .mapToPk() + .fieldName("event_id") + .deleteRule("cascade"), + authId: () => + p + .manyToOne(HackKitProfile) + .mapToPk() + .fieldName("auth_id") + .deleteRule("cascade"), + scannedByAuthId: () => + p + .manyToOne(HackKitProfile) + .mapToPk() + .fieldName("scanned_by_auth_id"), + scannedAt: p.datetime().onCreate(() => new Date()), + }, + indexes: [ + { properties: ["eventId"] }, + { properties: ["authId"] }, + { properties: ["eventId", "authId"] }, + { properties: ["scannedAt"] }, + ], +}); + +export const CoreNotificationIntent = defineEntity({ + name: "CoreNotificationIntent", + tableName: "core_notification_intent", + properties: { + id: p + .string() + .primary() + .onCreate(() => crypto.randomUUID()), + kind: p.string(), + recipientAuthId: () => + p + .manyToOne(HackKitProfile) + .mapToPk() + .fieldName("recipient_auth_id") + .nullable() + .deleteRule("set null"), + payload: p.json>().onCreate(() => ({})), + status: p + .enum([ + "pending", + "processing", + "delivered", + "failed", + "skipped", + ] as const) + .default("pending"), + idempotencyKey: p.string().nullable().unique(), + createdAt: p.datetime().onCreate(() => new Date()), + updatedAt: p + .datetime() + .onCreate(() => new Date()) + .onUpdate(() => new Date()), + }, + indexes: [ + { properties: ["kind"] }, + { properties: ["recipientAuthId"] }, + { properties: ["status"] }, + { properties: ["createdAt"] }, + ], +}); + +export const CoreNotificationDeliveryAttempt = defineEntity({ + name: "CoreNotificationDeliveryAttempt", + tableName: "core_notification_delivery_attempt", + properties: { + id: p + .string() + .primary() + .onCreate(() => crypto.randomUUID()), + intentId: () => + p + .manyToOne(CoreNotificationIntent) + .mapToPk() + .fieldName("intent_id") + .deleteRule("cascade"), + channel: p.string(), + provider: p.string().nullable(), + status: p.enum(["delivered", "failed", "skipped"] as const), + recipient: p.string().nullable(), + externalId: p.string().nullable(), + error: p.text().nullable(), + metadata: p.json>().onCreate(() => ({})), + attemptedAt: p.datetime().onCreate(() => new Date()), + }, + indexes: [ + { properties: ["intentId"] }, + { properties: ["channel"] }, + { properties: ["status"] }, + { properties: ["attemptedAt"] }, + ], +}); + +// Updating a named row serializes domain decisions that depend on row counts. +export const CoreOperationLock = defineEntity({ + name: "CoreOperationLock", + tableName: "core_operation_lock", + properties: { + key: p.string().primary(), + token: p.string(), + }, +}); export const coreModels = { - user: defineModel("core.user", { - fields: { - authId: field.string().primaryKey(), - email: field.string().unique(), - firstName: field.string(), - lastName: field.string(), - profilePhotoUrl: field.string().optional(), - hackTag: field.string().optional().unique(), - bio: field.string().optional(), - pronouns: field.string().optional(), - skills: field.json().default([]), - isProfileSearchable: field.boolean().default(true), - discordDisplayHandle: field.string().optional(), - roleId: field - .string() - .optional() - .references("core.role", "id", { onDelete: "setNull" }), - isApproved: field.boolean().default(false), - checkedInAt: field.date().optional(), - createdAt: field.date().defaultNow(), - updatedAt: field.date().defaultNow(), - }, - indexes: [["email"], ["hackTag"], ["roleId"], ["createdAt"]], - }), - userData: defineModel("core.userData", { - fields: { - authId: field - .string() - .primaryKey() - .references("core.user", "authId", { onDelete: "cascade" }), - age: field.integer(), - gender: field.string(), - race: field.string(), - ethnicity: field.string(), - shirtSize: field.string(), - dietaryRestrictions: field.json().default([]), - accommodationNote: field.string().optional(), - phoneNumber: field.string().optional(), - countryOfResidence: field.string().optional(), - hasAcceptedMLHCodeOfConduct: field.boolean(), - hasSharedDataWithMLH: field.boolean(), - isEmailable: field.boolean(), - completedAt: field.date().defaultNow(), - updatedAt: field.date().defaultNow(), - }, - }), - hacker: defineModel("core.hacker", { - fields: { - authId: field - .string() - .primaryKey() - .references("core.user", "authId", { onDelete: "cascade" }), - university: field.string(), - major: field.string(), - schoolId: field.string().optional(), - levelOfStudy: field.string(), - hackathonsAttended: field.integer(), - softwareExperience: field.string(), - heardFrom: field.string().optional(), - githubUrl: field.string().optional(), - linkedInUrl: field.string().optional(), - personalWebsiteUrl: field.string().optional(), - resumeUrl: field.string().optional(), - group: field.string().optional(), - registeredAt: field.date().defaultNow(), - updatedAt: field.date().defaultNow(), - }, - indexes: [["registeredAt"]], - }), - rsvp: defineModel("core.rsvp", { - fields: { - authId: field - .string() - .primaryKey() - .references("core.user", "authId", { onDelete: "cascade" }), - status: field.enum(["confirmed", "waitlisted", "cancelled"] as const), - waitlistPosition: field.integer().optional(), - createdAt: field.date().defaultNow(), - updatedAt: field.date().defaultNow(), - confirmedAt: field.date().optional(), - waitlistedAt: field.date().optional(), - cancelledAt: field.date().optional(), - cancelledByAuthId: field - .string() - .optional() - .references("core.user", "authId", { onDelete: "setNull" }), - promotedAt: field.date().optional(), - promotedByAuthId: field - .string() - .optional() - .references("core.user", "authId", { onDelete: "setNull" }), - }, - indexes: [["status"], ["waitlistPosition"], ["createdAt"], ["updatedAt"]], - }), - role: defineModel("core.role", { - fields: { - id: field.string().primaryKey().defaultId(), - name: field.string().unique(), - position: field.integer(), - permissions: field.json<`${string}.${string}`[]>().default([]), - color: field.string().optional(), - createdAt: field.date().defaultNow(), - updatedAt: field.date().defaultNow(), - }, - indexes: [["position"]], - }), - userBan: defineModel("core.userBan", { - fields: { - authId: field - .string() - .primaryKey() - .references("core.user", "authId", { onDelete: "cascade" }), - reason: field.string().optional(), - bannedByAuthId: field.string().references("core.user", "authId"), - createdAt: field.date().defaultNow(), - }, - }), - event: defineModel("core.event", { - fields: { - id: field.string().primaryKey().defaultId(), - title: field.string(), - startTime: field.date(), - endTime: field.date(), - location: field.string().default("TBD"), - description: field.string(), - type: field.string(), - host: field.string().optional(), - hidden: field.boolean().default(false), - createdAt: field.date().defaultNow(), - updatedAt: field.date().defaultNow(), - }, - indexes: [["startTime"], ["type"], ["hidden"]], - }), - setting: defineModel("core.setting", { - fields: { - key: field.string().primaryKey(), - value: field.json(), - createdAt: field.date().defaultNow(), - createdByAuthId: field - .string() - .optional() - .references("core.user", "authId", { onDelete: "setNull" }), - updatedAt: field.date().defaultNow(), - updatedByAuthId: field - .string() - .optional() - .references("core.user", "authId", { onDelete: "setNull" }), - }, - indexes: [["updatedAt"], ["updatedByAuthId"]], - }), - eventScan: defineModel("core.eventScan", { - fields: { - id: field.string().primaryKey().defaultId(), - eventId: field - .string() - .references("core.event", "id", { onDelete: "cascade" }), - authId: field - .string() - .references("core.user", "authId", { onDelete: "cascade" }), - scannedByAuthId: field.string().references("core.user", "authId"), - scannedAt: field.date().defaultNow(), - }, - indexes: [ - ["eventId"], - ["authId"], - ["eventId", "authId"], - ["scannedAt"], - ], - }), - notificationIntent: defineModel("core.notificationIntent", { - fields: { - id: field.string().primaryKey().defaultId(), - kind: field.string(), - recipientAuthId: field - .string() - .optional() - .references("core.user", "authId", { onDelete: "setNull" }), - payload: field.json>().default({}), - status: field - .enum(["pending", "processing", "delivered", "failed", "skipped"] as const) - .default("pending"), - idempotencyKey: field.string().optional().unique(), - createdAt: field.date().defaultNow(), - updatedAt: field.date().defaultNow(), - }, - indexes: [["kind"], ["recipientAuthId"], ["status"], ["createdAt"]], - }), - notificationDeliveryAttempt: defineModel("core.notificationDeliveryAttempt", { - fields: { - id: field.string().primaryKey().defaultId(), - intentId: field - .string() - .references("core.notificationIntent", "id", { onDelete: "cascade" }), - channel: field.string(), - provider: field.string().optional(), - status: field.enum(["delivered", "failed", "skipped"] as const), - recipient: field.string().optional(), - externalId: field.string().optional(), - error: field.string().optional(), - metadata: field.json>().default({}), - attemptedAt: field.date().defaultNow(), - }, - indexes: [["intentId"], ["channel"], ["status"], ["attemptedAt"]], - }), + operationLock: CoreOperationLock, + user: HackKitProfile, + userData: CoreUserData, + hacker: CoreHacker, + rsvp: CoreRsvp, + role: CoreRole, + userBan: CoreUserBan, + event: CoreEvent, + setting: CoreSetting, + eventScan: CoreEventScan, + notificationIntent: CoreNotificationIntent, + notificationDeliveryAttempt: CoreNotificationDeliveryAttempt, } as const; diff --git a/packages/core/src/notifications.ts b/packages/core/src/notifications.ts index 54f0b349..88949184 100644 --- a/packages/core/src/notifications.ts +++ b/packages/core/src/notifications.ts @@ -1,12 +1,13 @@ +import { serialize } from "@mikro-orm/core"; import { z } from "zod"; -import type { DatabaseAdapter } from "./database"; -import { HackKitError, parseInput } from "./errors"; -import { coreModels } from "./models"; +import type { EntityManager } from "@mikro-orm/core"; +import { HackKitError, parseInput } from "./errors.js"; +import { coreModels } from "./models.js"; import type { AuthId, NotificationDeliveryAttempt, NotificationIntent, -} from "./types"; +} from "./types.js"; export const CoreNotificationKind = { UserApproved: "core.user.approved", @@ -82,7 +83,13 @@ const queueNotificationIntentSchema = z.object({ const listNotificationIntentsSchema = z .object({ status: z - .enum(["pending", "processing", "delivered", "failed", "skipped"] as const) + .enum([ + "pending", + "processing", + "delivered", + "failed", + "skipped", + ] as const) .optional(), kind: notificationKindSchema.optional(), recipientAuthId: z.string().min(1).optional(), @@ -126,19 +133,20 @@ export type DeliverPendingNotificationsResult = { }; export type NotificationsApiContext = { - db: DatabaseAdapter; + em: EntityManager; now: () => Date; }; -function optionalWhere>(where: T): Partial { +function optionalWhere>( + where: T, +): Partial { return Object.fromEntries( Object.entries(where).filter(([, value]) => value !== undefined), ) as Partial; } function parsePayload(kind: string, payload: Record) { - const schema = - coreNotificationPayloadSchemas[kind as CoreNotificationKind]; + const schema = coreNotificationPayloadSchemas[kind as CoreNotificationKind]; return schema ? schema.parse(payload) : payload; } @@ -155,16 +163,21 @@ function summarizeIntentStatus( } export function createNotificationsApi(context: NotificationsApiContext) { - const { db, now } = context; + const { em, now } = context; - async function getIntentOrThrow(intentId: string): Promise { - const intent = await db.findOne(coreModels.notificationIntent, { + async function getIntentOrThrow( + intentId: string, + ): Promise { + const intent = await em.findOne(coreModels.notificationIntent, { id: intentId, }); if (!intent) { - throw new HackKitError("NOT_FOUND", "Notification intent not found."); + throw new HackKitError( + "NOT_FOUND", + "Notification intent not found.", + ); } - return intent; + return serialize(intent); } async function recordDeliveryAttempt( @@ -172,10 +185,14 @@ export function createNotificationsApi(context: NotificationsApiContext) { ): Promise { const parsed = parseInput(recordDeliveryAttemptSchema, input); await getIntentOrThrow(parsed.intentId); - return db.insert(coreModels.notificationDeliveryAttempt, { - ...parsed, - attemptedAt: now(), - }); + { + const created = em.create(coreModels.notificationDeliveryAttempt, { + ...parsed, + attemptedAt: now(), + }); + await em.flush(); + return serialize(created); + } } return { @@ -185,47 +202,62 @@ export function createNotificationsApi(context: NotificationsApiContext) { const parsed = parseInput(queueNotificationIntentSchema, input); const payload = parsePayload(parsed.kind, parsed.payload); if (parsed.idempotencyKey) { - const existing = await db.findOne(coreModels.notificationIntent, { + const existing = await em.findOne( + coreModels.notificationIntent, + { + idempotencyKey: parsed.idempotencyKey, + }, + ); + if (existing) return serialize(existing); + } + const timestamp = now(); + { + const created = em.create(coreModels.notificationIntent, { + kind: parsed.kind, + recipientAuthId: parsed.recipientAuthId, + payload, idempotencyKey: parsed.idempotencyKey, + status: "pending", + createdAt: timestamp, + updatedAt: timestamp, }); - if (existing) return existing; + await em.flush(); + return serialize(created); } - const timestamp = now(); - return db.insert(coreModels.notificationIntent, { - kind: parsed.kind, - recipientAuthId: parsed.recipientAuthId, - payload, - idempotencyKey: parsed.idempotencyKey, - status: "pending", - createdAt: timestamp, - updatedAt: timestamp, - }); }, async getIntent(intentId: string): Promise { - return db.findOne(coreModels.notificationIntent, { id: intentId }); + const record = await em.findOne(coreModels.notificationIntent, { + id: intentId, + }); + return record ? serialize(record) : null; }, async listIntents(input?: unknown): Promise { const parsed = parseInput(listNotificationIntentsSchema, input); - return db.findMany(coreModels.notificationIntent, { - where: optionalWhere({ - status: parsed?.status, - kind: parsed?.kind, - recipientAuthId: parsed?.recipientAuthId, - }), - orderBy: { field: "createdAt", direction: "desc" }, - limit: parsed?.limit, - }); + return serialize( + await em.find( + coreModels.notificationIntent, + optionalWhere({ + status: parsed?.status, + kind: parsed?.kind, + recipientAuthId: parsed?.recipientAuthId, + }), + { orderBy: { createdAt: "desc" }, limit: parsed?.limit }, + ), + ); }, async listDeliveryAttempts( intentId: string, ): Promise { - return db.findMany(coreModels.notificationDeliveryAttempt, { - where: { intentId }, - orderBy: { field: "attemptedAt", direction: "desc" }, - }); + return serialize( + await em.find( + coreModels.notificationDeliveryAttempt, + { intentId }, + { orderBy: { attemptedAt: "desc" } }, + ), + ); }, recordDeliveryAttempt, @@ -236,25 +268,30 @@ export function createNotificationsApi(context: NotificationsApiContext) { if (input.channels.length === 0) { return { intentsProcessed: 0, attempts: [] }; } - const intents = await db.findMany(coreModels.notificationIntent, { - where: { status: "pending" }, - orderBy: { field: "createdAt", direction: "asc" }, - limit: input.limit ?? 25, - }); + const intents = await em.find( + coreModels.notificationIntent, + { status: "pending" }, + { orderBy: { createdAt: "asc" }, limit: input.limit ?? 25 }, + ); const attempts: NotificationDeliveryAttempt[] = []; + let intentsProcessed = 0; for (const intent of intents) { - await db.update( + const claimed = await em.nativeUpdate( coreModels.notificationIntent, - { id: intent.id }, + { id: intent.id, status: "pending" }, { status: "processing", updatedAt: now() }, ); + if (claimed === 0) continue; + await em.refresh(intent); + intentsProcessed++; + const intentAttempts: NotificationDeliveryAttempt[] = []; for (const channel of input.channels) { let result: NotificationDeliveryResult; try { - result = await channel.deliver(intent); + result = await channel.deliver(serialize(intent)); } catch (error) { result = { status: "failed", @@ -273,17 +310,12 @@ export function createNotificationsApi(context: NotificationsApiContext) { attempts.push(attempt); } - await db.update( - coreModels.notificationIntent, - { id: intent.id }, - { - status: summarizeIntentStatus(intentAttempts), - updatedAt: now(), - }, - ); + intent.status = summarizeIntentStatus(intentAttempts); + intent.updatedAt = now(); + await em.flush(); } - return { intentsProcessed: intents.length, attempts }; + return { intentsProcessed, attempts }; }, }; } diff --git a/packages/core/src/permissions.ts b/packages/core/src/permissions.ts index abbede65..55436db4 100644 --- a/packages/core/src/permissions.ts +++ b/packages/core/src/permissions.ts @@ -1,4 +1,4 @@ -import type { PermissionKey } from "./types"; +import type { PermissionKey } from "./types.js"; export const CorePermission = { Admin: "core.admin", diff --git a/packages/core/src/plugins.ts b/packages/core/src/plugins.ts index eaf8e5ab..eca0c0fc 100644 --- a/packages/core/src/plugins.ts +++ b/packages/core/src/plugins.ts @@ -1,22 +1,24 @@ +import type { EntityManager, EntitySchema } from "@mikro-orm/core"; +import { authEntities } from "./mikro/auth-entities.js"; +import { HackKitError } from "./errors.js"; +import { coreModels } from "./models.js"; +import type { NotificationsApi } from "./notifications.js"; +import { CorePermission } from "./permissions.js"; import type { - DatabaseAdapter, - PersistentModel, - StorageRegistry, -} from "./database"; -import { HackKitError } from "./errors"; -import { coreModels } from "./models"; -import type { NotificationsApi } from "./notifications"; -import { CorePermission } from "./permissions"; -import type { HackathonSettingDefinition, SettingKey, SettingValue } from "./settings"; -import { coreSettings } from "./settings"; -import type { PermissionKey } from "./types"; -import type { HackkitGroup } from "./groups"; + HackathonSettingDefinition, + SettingKey, + SettingValue, +} from "./settings.js"; +import { coreSettings } from "./settings.js"; +import type { PermissionKey } from "./types.js"; +import type { HackkitGroup } from "./groups.js"; -type ModelMap = Record; type PermissionMap = Record; export type HackKitPluginContext = { - database: DatabaseAdapter; + em: EntityManager; + actorAuthId?: string; + getUser: (authId: string) => Promise; registry: HackKitRegistry; getSettingValue: (key: SettingKey) => Promise; notifications: NotificationsApi; @@ -34,7 +36,7 @@ export type HackKitPlugin< actionFactory?: string; /** Server action names exposed by the plugin action factory. */ actionNames?: readonly string[]; - models?: ModelMap; + entities?: readonly EntitySchema[]; permissions?: PermissionMap; settings?: readonly HackathonSettingDefinition[]; setup?: (context: HackKitPluginContext) => TApi; @@ -50,22 +52,21 @@ export type PluginApiMap = { }; export type HackKitRegistry = { - models: Record; + entities: EntitySchema[]; permissions: Record; plugins: Record; settings: readonly HackathonSettingDefinition[]; - storage: StorageRegistry; }; export function createPluginRegistry( plugins: readonly HackKitPlugin[] = [], + entities: readonly EntitySchema[] = [], ): HackKitRegistry { const registry: HackKitRegistry = { - models: { ...coreModels }, + entities: [...authEntities, ...Object.values(coreModels)], permissions: { ...CorePermission }, plugins: {}, settings: [...coreSettings], - storage: { models: { ...coreModels } }, }; for (const plugin of plugins) { @@ -78,22 +79,15 @@ export function createPluginRegistry( registry.plugins[plugin.id] = plugin; - for (const [name, pluginModel] of Object.entries(plugin.models ?? {})) { - const modelKey = pluginModel.key; - if (!modelKey.startsWith(`${plugin.id}.`)) { + for (const entity of plugin.entities ?? []) { + const prefix = `${plugin.id.replaceAll("-", "_")}_`; + if (!entity.meta.tableName?.startsWith(prefix)) { throw new HackKitError( "INVALID_OPERATION", - `Plugin model '${name}' must use the '${plugin.id}.' namespace.`, + `Plugin entity '${entity.meta.className}' must use the '${prefix}' table namespace.`, ); } - if (registry.models[modelKey]) { - throw new HackKitError( - "CONFLICT", - `HackKit model '${modelKey}' is already registered.`, - ); - } - registry.models[modelKey] = pluginModel; - registry.storage.models[modelKey] = pluginModel; + registry.entities.push(entity); } for (const setting of plugin.settings ?? []) { @@ -103,7 +97,11 @@ export function createPluginRegistry( `Plugin setting '${setting.key}' must use the '${plugin.id}.' namespace.`, ); } - if (registry.settings.some((existing) => existing.key === setting.key)) { + if ( + registry.settings.some( + (existing) => existing.key === setting.key, + ) + ) { throw new HackKitError( "CONFLICT", `HackKit setting '${setting.key}' is already registered.`, @@ -131,6 +129,25 @@ export function createPluginRegistry( } } + registry.entities.push(...entities); + const names = new Set(); + const tables = new Set(); + for (const entity of registry.entities) { + const name = entity.meta.className; + const table = entity.meta.tableName; + if (!table) + throw new HackKitError( + "INVALID_OPERATION", + `Entity '${name}' must declare a tableName.`, + ); + if (names.has(name) || tables.has(table)) + throw new HackKitError( + "CONFLICT", + `Duplicate entity name or table: '${name}' / '${table}'.`, + ); + names.add(name); + tables.add(table); + } return registry; } diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts index ce3c130d..980cefb6 100644 --- a/packages/core/src/schemas.ts +++ b/packages/core/src/schemas.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { createCompleteUserDataSchema, defaultUserDataOptions, -} from "./user-data-options"; +} from "./user-data-options.js"; export const permissionKeySchema = z .string() @@ -16,14 +16,6 @@ export const hackTagSchema = z .regex(/^[a-zA-Z0-9_-]+$/) .transform((value) => value.toLowerCase()); -export const ensureUserSchema = z.object({ - authId: authIdSchema, - email: z.string().email(), - firstName: z.string().min(1).max(100), - lastName: z.string().min(1).max(100), - profilePhotoUrl: z.string().url().optional(), -}); - export const updateUserProfileSchema = z.object({ authId: authIdSchema, firstName: z.string().min(1).max(100).optional(), diff --git a/packages/core/src/seed.ts b/packages/core/src/seed.ts index 1119818e..b9c9f046 100644 --- a/packages/core/src/seed.ts +++ b/packages/core/src/seed.ts @@ -1,10 +1,6 @@ -import { - isDatabaseAdapterFactory, - type DatabaseAdapterInput, -} from "./database"; -import { coreModels } from "./models"; -import { createPluginRegistry, type HackKitPlugin } from "./plugins"; -import type { PermissionKey } from "./types"; +import type { EntityManager } from "@mikro-orm/core"; +import { coreModels } from "./models.js"; +import type { PermissionKey } from "./types.js"; export type SeedRoleInput = { id: string; @@ -15,11 +11,9 @@ export type SeedRoleInput = { }; export type SeedRolesOptions = { - database: DatabaseAdapterInput; - plugins?: readonly HackKitPlugin[]; + em: EntityManager; roles: readonly SeedRoleInput[]; clock?: () => Date; - id?: () => string; }; export type SeedRolesResult = { @@ -27,26 +21,17 @@ export type SeedRolesResult = { skippedRoleIds: string[]; }; -const defaultId = () => - globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2); - export async function seedRoles( options: SeedRolesOptions, ): Promise { const now = options.clock ?? (() => new Date()); - const registry = createPluginRegistry(options.plugins ?? []); - const database = isDatabaseAdapterFactory(options.database) - ? options.database.create({ - storage: registry.storage, - now, - id: options.id ?? defaultId, - }) - : options.database; + + const em = options.em; const insertedRoleIds: string[] = []; const skippedRoleIds: string[] = []; for (const role of options.roles) { - const existing = await database.findOne(coreModels.role, { + const existing = await em.findOne(coreModels.role, { id: role.id, }); if (existing) { @@ -55,11 +40,12 @@ export async function seedRoles( } const timestamp = now(); - await database.insert(coreModels.role, { + em.create(coreModels.role, { ...role, createdAt: timestamp, updatedAt: timestamp, }); + await em.flush(); insertedRoleIds.push(role.id); } diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts index cc43fa01..80fe9d7e 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { HackKitError } from "./errors"; +import { HackKitError } from "./errors.js"; export type SettingKey = `${string}.${string}`; export type SettingValueType = "boolean" | "number"; @@ -32,9 +32,11 @@ export type HackathonSettingDefinition = | NumberSettingDefinition; export type SettingValueForDefinition = - TDefinition extends BooleanSettingDefinition ? boolean - : TDefinition extends NumberSettingDefinition ? number - : SettingValue; + TDefinition extends BooleanSettingDefinition + ? boolean + : TDefinition extends NumberSettingDefinition + ? number + : SettingValue; export type ResolvedHackathonSetting< TDefinition extends HackathonSettingDefinition = HackathonSettingDefinition, @@ -74,7 +76,8 @@ export const coreSettings = [ type: "boolean", defaultValue: false, label: "Require organiser approval", - description: "Require organisers to approve hackers before they receive full participant access.", + description: + "Require organisers to approve hackers before they receive full participant access.", category: "Registration", }), defineSetting({ @@ -85,7 +88,8 @@ export const coreSettings = [ min: 0, unit: "hackers", label: "Maximum registrations", - description: "Maximum number of hackers who may register. 0 means unlimited.", + description: + "Maximum number of hackers who may register. 0 means unlimited.", category: "Registration", }), defineSetting({ @@ -96,7 +100,8 @@ export const coreSettings = [ min: 0, unit: "hackers", label: "Hackathon capacity", - description: "Maximum number of hackers who may be approved. 0 means unlimited.", + description: + "Maximum number of hackers who may be approved. 0 means unlimited.", category: "Registration", }), defineSetting({ @@ -123,7 +128,8 @@ export const coreSettings = [ type: "boolean", defaultValue: false, label: "RSVP waitlist", - description: "Place hackers on an ordered waitlist after the RSVP limit is reached.", + description: + "Place hackers on an ordered waitlist after the RSVP limit is reached.", category: "RSVP", }), defineSetting({ @@ -134,7 +140,8 @@ export const coreSettings = [ min: 1000, unit: "milliseconds", label: "Event Pass QR TTL", - description: "How long an Event Pass QR code remains valid, in milliseconds.", + description: + "How long an Event Pass QR code remains valid, in milliseconds.", category: "Events", }), ] as const; @@ -144,9 +151,9 @@ export type CoreSettingValueMap = { [Definition in CoreSettingDefinition as Definition["key"]]: SettingValueForDefinition; }; -export function defineSetting( - definition: TDefinition, -): TDefinition { +export function defineSetting< + const TDefinition extends HackathonSettingDefinition, +>(definition: TDefinition): TDefinition { return definition; } @@ -160,8 +167,10 @@ export function validateSettingValue( } else { let numberSchema = z.number(); if (definition.integer) numberSchema = numberSchema.int(); - if (definition.min !== undefined) numberSchema = numberSchema.min(definition.min); - if (definition.max !== undefined) numberSchema = numberSchema.max(definition.max); + if (definition.min !== undefined) + numberSchema = numberSchema.min(definition.min); + if (definition.max !== undefined) + numberSchema = numberSchema.max(definition.max); schema = numberSchema; } const result = schema.safeParse(value); diff --git a/packages/core/src/testing.ts b/packages/core/src/testing.ts new file mode 100644 index 00000000..5ef79b2f --- /dev/null +++ b/packages/core/src/testing.ts @@ -0,0 +1,71 @@ +import { afterEach } from "vitest"; +import { SqliteDriver } from "@mikro-orm/sqlite"; +import { + initializeHackkit, + type InitializeHackkitOptions, + type HackKitScope, + type HackKitRuntime, +} from "./mikro/runtime.js"; +import { AuthUser } from "./mikro/auth-entities.js"; +import type { HackKitPlugin } from "./plugins.js"; +import type { EntityManager } from "@mikro-orm/core"; + +const cleanups = new Set<() => Promise>(); +afterEach(async () => { + await Promise.all([...cleanups].map((close) => close())); + cleanups.clear(); +}); + +/** A real, isolated SQLite database for tests of Core and server plugins. */ +export async function createTestHackkit< + const TPlugins extends readonly HackKitPlugin[] = [], +>( + options: Omit, "database" | "auth"> = {}, +): Promise> { + let sequence = 0; + let tick = 0; + const runtime = await initializeHackkit({ + clock: () => new Date(Date.UTC(2026, 4, 24, 12) + tick++ * 1000), + id: () => `id-${++sequence}`, + logger: { disabled: true }, + ...options, + database: { driver: SqliteDriver, dbName: ":memory:" }, + auth: { + secret: "test-secret-that-is-at-least-32-characters", + baseURL: "http://localhost:3000", + emailAndPassword: { enabled: true }, + }, + }); + cleanups.add(() => runtime.orm.close()); + await runtime.orm.schema.create(); + const scope = runtime.createScope(); + return Object.assign(scope.hackkit, { em: scope.em, runtime }); +} + +/** Seed a known identity without hashing a password in every domain test. */ +export async function createTestUser( + context: { em: EntityManager }, + input: { + authId: string; + email: string; + firstName: string; + lastName: string; + profilePhotoUrl?: string; + }, +) { + context.em.create(AuthUser, { + id: input.authId, + email: input.email, + name: `${input.firstName} ${input.lastName}`, + image: input.profilePhotoUrl, + createdAt: new Date("2026-05-24T12:00:00.000Z"), + updatedAt: new Date("2026-05-24T12:00:00.000Z"), + }); + await context.em.flush(); +} + +export type TestHackkit = + HackKitScope["hackkit"] & { + em: EntityManager; + runtime: HackKitRuntime; + }; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 2e9ca31f..5f4f265c 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,40 +1,29 @@ -import type { InferInsert, InferSelect } from "./database"; -import type { coreModels } from "./models"; +import type { InferEntity, EntityDTO } from "@mikro-orm/core"; +import type { coreModels } from "./models.js"; export type AuthId = string; export type UserId = AuthId; export type RoleId = string; export type PermissionKey = `${string}.${string}`; -export type User = InferSelect; -export type NewUser = InferInsert; -export type UserData = InferSelect; -export type NewUserData = InferInsert; -export type Hacker = InferSelect; -export type NewHacker = InferInsert; -export type Rsvp = InferSelect; -export type NewRsvp = InferInsert; -export type Role = InferSelect; -export type NewRole = InferInsert; -export type UserBan = InferSelect; -export type NewUserBan = InferInsert; -export type Event = InferSelect; -export type NewEvent = InferInsert; -export type EventScan = InferSelect; -export type NewEventScan = InferInsert; -export type HackathonSetting = InferSelect; -export type NewHackathonSetting = InferInsert; -export type NotificationIntent = InferSelect< - typeof coreModels.notificationIntent ->; -export type NewNotificationIntent = InferInsert< - typeof coreModels.notificationIntent +export type User = EntityDTO> & { + email: string; +}; +export type UserData = EntityDTO>; +export type Hacker = EntityDTO>; +export type Rsvp = EntityDTO>; +export type Role = EntityDTO>; +export type UserBan = EntityDTO>; +export type Event = EntityDTO>; +export type EventScan = EntityDTO>; +export type HackathonSetting = EntityDTO< + InferEntity >; -export type NotificationDeliveryAttempt = InferSelect< - typeof coreModels.notificationDeliveryAttempt +export type NotificationIntent = EntityDTO< + InferEntity >; -export type NewNotificationDeliveryAttempt = InferInsert< - typeof coreModels.notificationDeliveryAttempt +export type NotificationDeliveryAttempt = EntityDTO< + InferEntity >; export type AdminUserRecord = { diff --git a/packages/core/src/user-data-options.ts b/packages/core/src/user-data-options.ts index 98bf2a61..5a4b1056 100644 --- a/packages/core/src/user-data-options.ts +++ b/packages/core/src/user-data-options.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { HackKitError } from "./errors"; +import { HackKitError } from "./errors.js"; export type UserDataOption = { value: string; @@ -158,11 +158,9 @@ export function createCompleteUserDataSchema(options: UserDataOptions) { ), dietaryRestrictions: z .array( - z - .string() - .refine((value) => dietaryValues.has(value), { - message: "Select a valid dietary restriction.", - }), + z.string().refine((value) => dietaryValues.has(value), { + message: "Select a valid dietary restriction.", + }), ) .default([]), accommodationNote: z.string().optional(), diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 16e73196..9c6e4cc6 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -2,8 +2,8 @@ "extends": "../tsconfig/base.json", "compilerOptions": { "target": "ES2022", - "module": "ES2022", - "moduleResolution": "Bundler", + "module": "NodeNext", + "moduleResolution": "NodeNext", "rootDir": "src", "outDir": "dist", "declaration": true, @@ -11,6 +11,11 @@ "sourceMap": true, "noEmit": false }, - "include": ["src/**/*.ts"], - "exclude": ["dist", "node_modules"] + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "dist", + "node_modules" + ] } diff --git a/packages/db-drizzle/package.json b/packages/db-drizzle/package.json deleted file mode 100644 index 7b1e070f..00000000 --- a/packages/db-drizzle/package.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "name": "@hackkit/db-drizzle", - "version": "0.0.0", - "description": "Drizzle database adapters and native schema generation for HackKit", - "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - }, - "./sqlite": { - "types": "./dist/sqlite.d.ts", - "import": "./dist/sqlite.js" - }, - "./libsql": { - "types": "./dist/libsql.d.ts", - "import": "./dist/libsql.js" - }, - "./postgres": { - "types": "./dist/postgres.d.ts", - "import": "./dist/postgres.js" - } - }, - "files": [ - "dist" - ], - "scripts": { - "build": "tsc -p tsconfig.json", - "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "vitest run" - }, - "dependencies": { - "@hackkit/core": "workspace:*", - "drizzle-orm": "^0.45.2" - }, - "devDependencies": { - "@libsql/client": "^0.14.0", - "drizzle-kit": "^0.31.4", - "postgres": "^3.4.7", - "typescript": "5.5.3", - "vitest": "^3.2.4" - }, - "license": "MIT", - "private": true -} diff --git a/packages/db-drizzle/src/__tests__/libsql-adapter.test.ts b/packages/db-drizzle/src/__tests__/libsql-adapter.test.ts deleted file mode 100644 index e7cf0304..00000000 --- a/packages/db-drizzle/src/__tests__/libsql-adapter.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { createClient } from "@libsql/client"; -import { defineModel, field } from "@hackkit/core"; -import { drizzle } from "drizzle-orm/libsql"; -import { describe, expect, it } from "vitest"; -import { createDrizzleLibsqlAdapter } from "../libsql"; - -describe("Drizzle libSQL database adapter", () => { - it("persists HackKit models through the public adapter factory", async () => { - const client = createClient({ url: ":memory:" }); - await client.execute( - 'CREATE TABLE "sample_note" ("id" TEXT PRIMARY KEY NOT NULL, "body" TEXT NOT NULL, "position" INTEGER NOT NULL)', - ); - const note = defineModel("sample.note", { - fields: { - id: field.string().primaryKey().defaultId(), - body: field.string(), - position: field.integer(), - }, - }); - let idSequence = 0; - const database = createDrizzleLibsqlAdapter(drizzle(client)).create({ - storage: { models: { note } }, - now: () => new Date("2026-01-01T00:00:00.000Z"), - id: () => `note-${++idSequence}`, - }); - - await expect( - database.insert(note, { body: "First", position: 2 }), - ).resolves.toEqual({ id: "note-1", body: "First", position: 2 }); - await database.insert(note, { body: "Second", position: 1 }); - - await expect( - database.findMany(note, { - orderBy: { field: "position", direction: "asc" }, - }), - ).resolves.toEqual([ - { id: "note-2", body: "Second", position: 1 }, - { id: "note-1", body: "First", position: 2 }, - ]); - - await expect( - database.update(note, { position: 2 }, { body: "Updated" }), - ).resolves.toEqual([{ id: "note-1", body: "Updated", position: 2 }]); - await expect(database.delete(note, { position: 1 })).resolves.toBe(1); - await expect( - database.findOne(note, { position: 1 }), - ).resolves.toBeNull(); - - client.close(); - }); -}); diff --git a/packages/db-drizzle/src/__tests__/postgres-adapter.test.ts b/packages/db-drizzle/src/__tests__/postgres-adapter.test.ts deleted file mode 100644 index b0e352f0..00000000 --- a/packages/db-drizzle/src/__tests__/postgres-adapter.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; -import { defineModel, field } from "@hackkit/core"; -import { drizzle } from "drizzle-orm/postgres-js"; -import { migrate } from "drizzle-orm/postgres-js/migrator"; -import postgres from "postgres"; -import { describe, expect, it } from "vitest"; -import { - createDrizzlePostgresAdapter, - createDrizzlePostgresSchemaAdapter, -} from "../postgres"; - -const postgresUrl = process.env.POSTGRES_TEST_URL; - -describe.runIf(postgresUrl)("Drizzle PostgreSQL database adapter", () => { - it("applies native migrations and supports CRUD", async () => { - const client = postgres(postgresUrl!, { max: 1 }); - const record = defineModel("sample.record", { - fields: { - id: field.string().primaryKey().defaultId(), - active: field.boolean(), - score: field.number(), - metadata: field.json<{ source: string }>(), - createdAt: field.date().defaultNow(), - }, - }); - const createdAt = new Date("2026-07-14T12:00:00.000Z"); - let idSequence = 0; - const db = drizzle(client); - - try { - await client.unsafe('DROP TABLE IF EXISTS "sample_record"'); - await client.unsafe("DROP SCHEMA IF EXISTS drizzle CASCADE"); - await migrate(db, { - migrationsFolder: join( - process.cwd(), - "test-fixtures/postgres/migrations", - ), - }); - const [generatedFile] = - await createDrizzlePostgresSchemaAdapter().generateSchemaFiles({ - storage: { models: { record } }, - }); - await expect( - readFile( - join(process.cwd(), "test-fixtures/postgres/schema.ts"), - "utf8", - ), - ).resolves.toBe(generatedFile.content); - - const database = createDrizzlePostgresAdapter(db).create({ - storage: { models: { record } }, - now: () => createdAt, - id: () => `record-${++idSequence}`, - }); - - await expect( - database.insert(record, { - active: true, - score: 9.5, - metadata: { source: "ci" }, - }), - ).resolves.toEqual({ - id: "record-1", - active: true, - score: 9.5, - metadata: { source: "ci" }, - createdAt, - }); - await database.insert(record, { - active: false, - score: 2, - metadata: { source: "second" }, - }); - - await expect( - database.findMany(record, { - orderBy: { field: "score", direction: "desc" }, - }), - ).resolves.toHaveLength(2); - await expect( - database.update(record, { id: "record-1" }, { score: 10.25 }), - ).resolves.toMatchObject([{ id: "record-1", score: 10.25 }]); - await expect( - database.delete(record, { id: "record-2" }), - ).resolves.toBe(1); - await expect( - database.findOne(record, { id: "record-2" }), - ).resolves.toBeNull(); - } finally { - await client.unsafe('DROP TABLE IF EXISTS "sample_record"'); - await client.unsafe("DROP SCHEMA IF EXISTS drizzle CASCADE"); - await client.end(); - } - }); -}); diff --git a/packages/db-drizzle/src/__tests__/schema-generation.test.ts b/packages/db-drizzle/src/__tests__/schema-generation.test.ts deleted file mode 100644 index 5ba5509a..00000000 --- a/packages/db-drizzle/src/__tests__/schema-generation.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { defineModel, field, type StorageRegistry } from "@hackkit/core"; -import ts from "typescript"; -import { describe, expect, it } from "vitest"; -import { createDrizzlePostgresSchemaAdapter } from "../postgres"; -import { - createDrizzleSqliteAdapter, - createDrizzleSqliteSchemaAdapter, -} from "../sqlite"; - -function createStorage(): StorageRegistry { - const user = defineModel("core.user", { - fields: { - id: field.string().primaryKey().defaultId(), - active: field.boolean().default(true), - createdAt: field.date().defaultNow(), - publishedAt: field - .date() - .default(new Date("2026-07-14T12:00:00.000Z")), - profile: field.json<{ nickname: string }>().optional(), - }, - }); - const entry = defineModel("sample.entry", { - fields: { - id: field.string().primaryKey().defaultId(), - userId: field - .string() - .references("core.user", "id", { onDelete: "cascade" }), - score: field.number(), - status: field - .enum(["draft", "published"] as const) - .default("draft"), - }, - unique: [["userId", "status"]], - indexes: [["score"]], - }); - - return { models: { user, entry } }; -} - -async function expectGeneratedSchemaToCompile(source: string): Promise { - const directory = await mkdtemp(join(process.cwd(), ".schema-compile-")); - const filePath = join(directory, "hackkit.ts"); - try { - await writeFile(filePath, source, "utf8"); - const program = ts.createProgram([filePath], { - target: ts.ScriptTarget.ES2022, - module: ts.ModuleKind.NodeNext, - moduleResolution: ts.ModuleResolutionKind.NodeNext, - noEmit: true, - skipLibCheck: true, - strict: true, - }); - const diagnostics = ts.getPreEmitDiagnostics(program); - expect( - diagnostics.map((diagnostic) => - ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), - ), - ).toEqual([]); - } finally { - await rm(directory, { recursive: true, force: true }); - } -} - -describe("Drizzle native schema generation", () => { - it("generates deterministic SQLite schema source", async () => { - const [file] = - await createDrizzleSqliteSchemaAdapter().generateSchemaFiles({ - storage: createStorage(), - }); - - expect(file.path).toBe("hackkit.ts"); - expect(file.content).toContain( - 'import { index, integer, real, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";', - ); - expect(file.content).toContain( - 'export const coreUser = sqliteTable("core_user"', - ); - expect(file.content).toContain( - 'references(() => coreUser.id, { onDelete: "cascade" })', - ); - expect(file.content).toContain( - 'integer("active", { mode: "boolean" }).notNull().default(true)', - ); - expect(file.content).toContain( - '.default(new Date("2026-07-14T12:00:00.000Z"))', - ); - expect(file.content).toContain( - 'sample_entry_userId_status_uniq: uniqueIndex("sample_entry_userId_status_uniq")', - ); - await expectGeneratedSchemaToCompile(file.content); - }); - - it("generates PostgreSQL schema with native PostgreSQL types", async () => { - const [file] = - await createDrizzlePostgresSchemaAdapter().generateSchemaFiles({ - storage: createStorage(), - }); - - expect(file.path).toBe("hackkit.ts"); - expect(file.content).toContain('from "drizzle-orm/pg-core"'); - expect(file.content).toContain( - 'export const coreUser = pgTable("core_user"', - ); - expect(file.content).toContain( - 'active: boolean("active").notNull().default(true)', - ); - expect(file.content).toContain( - 'createdAt: timestamp("createdAt", { withTimezone: true, mode: "date" }).notNull()', - ); - expect(file.content).toContain('profile: jsonb("profile")'); - expect(file.content).toContain( - 'score: doublePrecision("score").notNull()', - ); - await expectGeneratedSchemaToCompile(file.content); - }); - - it("keeps the committed PostgreSQL migration fixture on generated schema", async () => { - const record = defineModel("sample.record", { - fields: { - id: field.string().primaryKey().defaultId(), - active: field.boolean(), - score: field.number(), - metadata: field.json<{ source: string }>(), - createdAt: field.date().defaultNow(), - }, - }); - const [file] = - await createDrizzlePostgresSchemaAdapter().generateSchemaFiles({ - storage: { models: { record } }, - }); - - await expect( - readFile( - join(process.cwd(), "test-fixtures/postgres/schema.ts"), - "utf8", - ), - ).resolves.toBe(file.content); - }); - - it("rejects model keys that resolve to the same physical table", () => { - const first = defineModel("sample.entry.item", { - fields: { id: field.string().primaryKey() }, - }); - const second = defineModel("sample_entry.item", { - fields: { id: field.string().primaryKey() }, - }); - const storage = { models: { first, second } }; - - expect(() => - createDrizzleSqliteSchemaAdapter().generateSchemaFiles({ storage }), - ).toThrow("resolve to the same table 'sample_entry_item'"); - }); - - it("removes plugin tables when they leave the storage registry", async () => { - const storage = createStorage(); - const withoutPlugin = { - models: { user: storage.models.user }, - }; - const [file] = - await createDrizzleSqliteSchemaAdapter().generateSchemaFiles({ - storage: withoutPlugin, - }); - - expect(file.content).toContain("export const coreUser"); - expect(file.content).not.toContain("export const sampleEntry"); - }); - - it("is stable when registry insertion order changes", async () => { - const storage = createStorage(); - const reversed = { - models: Object.fromEntries( - Object.entries(storage.models).reverse(), - ), - }; - const adapter = createDrizzleSqliteSchemaAdapter(); - const [first] = await adapter.generateSchemaFiles({ storage }); - const [second] = await adapter.generateSchemaFiles({ - storage: reversed, - }); - - expect(second.content).toBe(first.content); - }); - - it("escapes valid storage names that are not TypeScript identifiers", async () => { - const external = defineModel("my-plugin.entry", { - fields: { - id: field.string().primaryKey(), - "external-id": field.string(), - }, - indexes: [["external-id"]], - }); - const [file] = - await createDrizzleSqliteSchemaAdapter().generateSchemaFiles({ - storage: { models: { external } }, - }); - - expect(file.content).toContain("export const my_pluginEntry"); - expect(file.content).toContain('"external-id": text("external-id")'); - expect(file.content).toContain('table["external-id"]'); - await expectGeneratedSchemaToCompile(file.content); - }); - - it("escapes physical names as TypeScript string literals", async () => { - const quoted = defineModel('quoted".entry\\path', { - fields: { - id: field.string().primaryKey(), - 'external"id\\path\nline': field.string(), - }, - indexes: [['external"id\\path\nline']], - }); - const [file] = - await createDrizzleSqliteSchemaAdapter().generateSchemaFiles({ - storage: { models: { quoted } }, - }); - - expect(file.content).toContain('sqliteTable("quoted\\"_entry\\\\path"'); - expect(file.content).toContain('text("external\\"id\\\\path\\nline")'); - await expectGeneratedSchemaToCompile(file.content); - }); - - it("rejects distinct tables that resolve to the same schema export", () => { - const first = defineModel("sample.entry.item", { - fields: { id: field.string().primaryKey() }, - }); - const second = defineModel("sample.entryItem", { - fields: { id: field.string().primaryKey() }, - }); - - expect(() => - createDrizzleSqliteSchemaAdapter().generateSchemaFiles({ - storage: { models: { first, second } }, - }), - ).toThrow("resolve to the same schema export 'sampleEntryItem'"); - }); - - it("does not resolve the runtime database while generating schema", async () => { - let resolutions = 0; - const adapter = createDrizzleSqliteAdapter(() => { - resolutions += 1; - return {}; - }); - - await adapter.schema!.generateSchemaFiles({ storage: createStorage() }); - - expect(resolutions).toBe(0); - }); -}); diff --git a/packages/db-drizzle/src/index.ts b/packages/db-drizzle/src/index.ts deleted file mode 100644 index b62f3774..00000000 --- a/packages/db-drizzle/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export type { DrizzleDialect } from "./schema"; diff --git a/packages/db-drizzle/src/libsql.ts b/packages/db-drizzle/src/libsql.ts deleted file mode 100644 index a657dda1..00000000 --- a/packages/db-drizzle/src/libsql.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - createDrizzleSqliteAdapter as createDrizzleLibsqlAdapter, - createDrizzleSqliteSchemaAdapter as createDrizzleLibsqlSchemaAdapter, - toDrizzleTableExportName, - toDrizzleTableName, -} from "./sqlite"; diff --git a/packages/db-drizzle/src/postgres.ts b/packages/db-drizzle/src/postgres.ts deleted file mode 100644 index b4558e54..00000000 --- a/packages/db-drizzle/src/postgres.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { AnyField, PersistentModel } from "@hackkit/core"; -import { - boolean, - doublePrecision, - integer, - jsonb, - pgTable, - text, - timestamp, -} from "drizzle-orm/pg-core"; -import { createDrizzleAdapterFactory } from "./runtime"; -import { createDrizzleSchemaAdapter } from "./schema"; - -export function createDrizzlePostgresSchemaAdapter() { - return createDrizzleSchemaAdapter("drizzle-postgres", "postgres"); -} - -function toColumn(name: string, field: AnyField) { - let column: any; - switch (field.kind) { - case "string": - case "enum": - column = text(name); - break; - case "integer": - column = integer(name); - break; - case "number": - column = doublePrecision(name); - break; - case "boolean": - column = boolean(name); - break; - case "date": - column = timestamp(name, { withTimezone: true, mode: "date" }); - break; - case "json": - column = jsonb(name); - break; - } - if (field.isPrimaryKey) column = column.primaryKey(); - if (field.isUnique) column = column.unique(); - if (!field.isOptional) column = column.notNull(); - return column; -} - -function createTable(model: PersistentModel) { - return pgTable( - model.key.replaceAll(".", "_"), - Object.fromEntries( - Object.entries(model.schema.fields).map(([name, field]) => [ - name, - toColumn(name, field), - ]), - ), - ) as never; -} - -export function createDrizzlePostgresAdapter( - database: unknown | (() => unknown), -) { - return createDrizzleAdapterFactory( - database, - createTable, - createDrizzlePostgresSchemaAdapter(), - ); -} - -export { toDrizzleTableExportName, toDrizzleTableName } from "./schema"; diff --git a/packages/db-drizzle/src/runtime.ts b/packages/db-drizzle/src/runtime.ts deleted file mode 100644 index 9d5ed352..00000000 --- a/packages/db-drizzle/src/runtime.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { and, asc, desc, eq } from "drizzle-orm"; -import type { - DatabaseAdapter, - DatabaseAdapterFactory, - DatabaseAdapterFactoryContext, - DatabaseSchemaAdapter, - InferInsert, - InferSelect, - Model, - ModelKey, - PersistentModel, -} from "@hackkit/core"; - -export type DrizzleTable = Record; -export type DrizzleTableFactory = (model: PersistentModel) => DrizzleTable; - -function applyDefaults( - model: TModel, - value: InferInsert, - context: DatabaseAdapterFactoryContext, -): InferSelect { - const row: Record = { ...value }; - - for (const [name, field] of Object.entries(model.schema.fields)) { - if (row[name] !== undefined || !field.defaultValue) continue; - switch (field.defaultValue.kind) { - case "static": - row[name] = field.defaultValue.value; - break; - case "now": - row[name] = context.now(); - break; - case "id": - row[name] = context.id(); - break; - } - } - - return row as InferSelect; -} - -function toWhere(table: DrizzleTable, where: Record) { - const clauses = Object.entries(where).map(([key, value]) => - eq(table[key] as never, value), - ); - if (clauses.length === 0) return undefined; - if (clauses.length === 1) return clauses[0]; - return and(...clauses); -} - -export function createDrizzleAdapterFactory( - database: unknown | (() => unknown), - createTable: DrizzleTableFactory, - schema: DatabaseSchemaAdapter, -): DatabaseAdapterFactory { - return { - schema, - create(context) { - const db: any = - typeof database === "function" ? database() : database; - const tables = Object.fromEntries( - Object.values(context.storage.models).map((model) => [ - model.key, - createTable(model), - ]), - ) as Record; - - const adapter: DatabaseAdapter = { - async insert(model, value) { - const [inserted] = await db - .insert(tables[model.key]) - .values(applyDefaults(model, value, context)) - .returning(); - return inserted as InferSelect; - }, - - async findOne(model, where) { - const [row] = await adapter.findMany(model, { - where, - limit: 1, - }); - return row ?? null; - }, - - async findMany(model, options) { - const table = tables[model.key]; - let query = db.select().from(table); - const condition = options?.where - ? toWhere( - table, - options.where as Record, - ) - : undefined; - if (condition) query = query.where(condition); - if (options?.orderBy) { - const column = table[String(options.orderBy.field)]; - query = query.orderBy( - options.orderBy.direction === "desc" - ? desc(column as never) - : asc(column as never), - ); - } - if (options?.limit !== undefined) - query = query.limit(options.limit); - return (await query) as InferSelect[]; - }, - - async update(model, where, patch) { - const table = tables[model.key]; - let query = db.update(table).set(patch); - const condition = toWhere( - table, - where as Record, - ); - if (condition) query = query.where(condition); - return (await query.returning()) as InferSelect< - typeof model - >[]; - }, - - async delete(model, where) { - const table = tables[model.key]; - let query = db.delete(table); - const condition = toWhere( - table, - where as Record, - ); - if (condition) query = query.where(condition); - const deleted = await query.returning(); - return deleted.length; - }, - }; - - return adapter; - }, - }; -} diff --git a/packages/db-drizzle/src/schema.ts b/packages/db-drizzle/src/schema.ts deleted file mode 100644 index 44b2d83c..00000000 --- a/packages/db-drizzle/src/schema.ts +++ /dev/null @@ -1,320 +0,0 @@ -import type { - AnyField, - DatabaseSchemaAdapter, - ModelKey, - PersistentModel, - ReferenceAction, - StorageRegistry, -} from "@hackkit/core"; - -export type DrizzleDialect = "sqlite" | "postgres"; - -type CompiledReference = { - tableExportName: string; - field: string; - onDelete?: ReferenceAction; - onUpdate?: ReferenceAction; -}; - -type CompiledColumn = { - name: string; - field: AnyField; - reference?: CompiledReference; -}; - -type CompiledIndex = { - name: string; - columns: readonly string[]; - unique: boolean; -}; - -type CompiledTable = { - tableName: string; - exportName: string; - columns: CompiledColumn[]; - indexes: CompiledIndex[]; -}; - -export function toDrizzleTableName(modelKey: ModelKey): string { - return modelKey.replaceAll(".", "_"); -} - -export function toDrizzleTableExportName(modelKey: ModelKey): string { - const [namespace = "", ...parts] = modelKey.split("."); - const candidate = [namespace, ...parts] - .map((part, index) => - index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1), - ) - .join(""); - return toIdentifier(candidate); -} - -function toIdentifier(value: string): string { - const sanitized = value.replace(/[^a-zA-Z0-9_$]/g, "_"); - return /^[a-zA-Z_$]/.test(sanitized) ? sanitized : `_${sanitized}`; -} - -function isIdentifier(value: string): boolean { - return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(value); -} - -function renderPropertyKey(value: string): string { - return isIdentifier(value) ? value : JSON.stringify(value); -} - -function renderPropertyAccess(object: string, property: string): string { - return isIdentifier(property) - ? `${object}.${property}` - : `${object}[${JSON.stringify(property)}]`; -} - -function toIndexName( - tableName: string, - columns: readonly string[], - unique: boolean, -): string { - return `${tableName}_${columns.join("_")}_${unique ? "uniq" : "idx"}`; -} - -function toDrizzleReferenceAction(action: ReferenceAction): string { - switch (action) { - case "setNull": - return "set null"; - case "noAction": - return "no action"; - default: - return action; - } -} - -function sortModelsByReferences(storage: StorageRegistry): PersistentModel[] { - const byKey = new Map( - Object.values(storage.models).map((model) => [model.key, model]), - ); - const visited = new Set(); - const visiting = new Set(); - const sorted: PersistentModel[] = []; - - function visit(model: PersistentModel) { - if (visited.has(model.key) || visiting.has(model.key)) return; - visiting.add(model.key); - const referencedModels = Object.values(model.schema.fields) - .flatMap((field) => { - const referenced = field.reference - ? byKey.get(field.reference.model) - : undefined; - return referenced ? [referenced] : []; - }) - .sort((left, right) => left.key.localeCompare(right.key)); - for (const referenced of referencedModels) { - if (referenced && referenced.key !== model.key) visit(referenced); - } - visiting.delete(model.key); - visited.add(model.key); - sorted.push(model); - } - - for (const model of [...byKey.values()].sort((left, right) => - left.key.localeCompare(right.key), - )) { - visit(model); - } - return sorted; -} - -function compileTables(storage: StorageRegistry): CompiledTable[] { - const tableOwners = new Map(); - const exportOwners = new Map(); - - return sortModelsByReferences(storage).map((model) => { - const tableName = toDrizzleTableName(model.key); - const exportName = toDrizzleTableExportName(model.key); - const existingOwner = tableOwners.get(tableName); - if (existingOwner) { - throw new Error( - `Models '${existingOwner}' and '${model.key}' resolve to the same table '${tableName}'.`, - ); - } - tableOwners.set(tableName, model.key); - const existingExportOwner = exportOwners.get(exportName); - if (existingExportOwner) { - throw new Error( - `Models '${existingExportOwner}' and '${model.key}' resolve to the same schema export '${exportName}'.`, - ); - } - exportOwners.set(exportName, model.key); - - const columns = Object.entries(model.schema.fields).map( - ([name, field]) => { - if (field.reference && !storage.models[field.reference.model]) { - const targetExists = Object.values(storage.models).some( - (candidate) => candidate.key === field.reference?.model, - ); - if (!targetExists) { - throw new Error( - `Model '${model.key}' references unknown model '${field.reference.model}'.`, - ); - } - } - - return { - name, - field, - reference: field.reference - ? { - tableExportName: toDrizzleTableExportName( - field.reference.model, - ), - field: field.reference.field, - onDelete: field.reference.onDelete, - onUpdate: field.reference.onUpdate, - } - : undefined, - }; - }, - ); - - const indexes: CompiledIndex[] = [ - ...(model.schema.unique ?? []).map((columns) => ({ - name: toIndexName(tableName, columns, true), - columns, - unique: true, - })), - ...(model.schema.indexes ?? []).map((columns) => ({ - name: toIndexName(tableName, columns, false), - columns, - unique: false, - })), - ]; - - return { - tableName, - exportName, - columns, - indexes, - }; - }); -} - -function renderDefault(field: AnyField): string | undefined { - if (field.defaultValue?.kind !== "static") return undefined; - const value = field.defaultValue.value; - const source = - field.kind === "date" && value instanceof Date - ? `new Date(${JSON.stringify(value.toISOString())})` - : JSON.stringify(value); - return `.default(${source})`; -} - -function renderReference(reference: CompiledReference): string { - const options = [ - reference.onDelete - ? `onDelete: "${toDrizzleReferenceAction(reference.onDelete)}"` - : undefined, - reference.onUpdate - ? `onUpdate: "${toDrizzleReferenceAction(reference.onUpdate)}"` - : undefined, - ].filter(Boolean); - const optionsSource = - options.length > 0 ? `, { ${options.join(", ")} }` : ""; - return `.references(() => ${renderPropertyAccess(reference.tableExportName, reference.field)}${optionsSource})`; -} - -function renderColumnFactory( - field: AnyField, - name: string, - dialect: DrizzleDialect, -) { - const physicalName = JSON.stringify(name); - if (dialect === "sqlite") { - switch (field.kind) { - case "string": - case "enum": - return `text(${physicalName})`; - case "integer": - return `integer(${physicalName})`; - case "number": - return `real(${physicalName})`; - case "boolean": - return `integer(${physicalName}, { mode: "boolean" })`; - case "date": - return `integer(${physicalName}, { mode: "timestamp" })`; - case "json": - return `text(${physicalName}, { mode: "json" })`; - } - } - - switch (field.kind) { - case "string": - case "enum": - return `text(${physicalName})`; - case "integer": - return `integer(${physicalName})`; - case "number": - return `doublePrecision(${physicalName})`; - case "boolean": - return `boolean(${physicalName})`; - case "date": - return `timestamp(${physicalName}, { withTimezone: true, mode: "date" })`; - case "json": - return `jsonb(${physicalName})`; - } -} - -function renderTable(table: CompiledTable, dialect: DrizzleDialect): string { - const columns = table.columns.map((column) => { - let source = renderColumnFactory(column.field, column.name, dialect); - if (column.field.isPrimaryKey) source += ".primaryKey()"; - if (!column.field.isOptional) source += ".notNull()"; - if (column.field.isUnique) source += ".unique()"; - if (column.reference) source += renderReference(column.reference); - source += renderDefault(column.field) ?? ""; - return `\t${renderPropertyKey(column.name)}: ${source}`; - }); - const indexes = table.indexes.map((definition) => { - const factory = definition.unique ? "uniqueIndex" : "index"; - const indexedColumns = definition.columns - .map((column) => renderPropertyAccess("table", column)) - .join(", "); - return `\t${renderPropertyKey(definition.name)}: ${factory}(${JSON.stringify(definition.name)}).on(${indexedColumns})`; - }); - const extraConfig = - indexes.length > 0 ? `, (table) => ({\n${indexes.join(",\n")}\n})` : ""; - const tableFactory = dialect === "sqlite" ? "sqliteTable" : "pgTable"; - return `export const ${table.exportName} = ${tableFactory}(${JSON.stringify(table.tableName)}, {\n${columns.join(",\n")}\n}${extraConfig});\n`; -} - -function importsFor(dialect: DrizzleDialect): string { - return dialect === "sqlite" - ? 'import { index, integer, real, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";' - : 'import { boolean, doublePrecision, index, integer, jsonb, pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core";'; -} - -export function compileDrizzleSchema( - storage: StorageRegistry, - dialect: DrizzleDialect, -): string { - const tables = compileTables(storage); - return [ - importsFor(dialect), - "", - ...tables.map((table) => renderTable(table, dialect)), - ].join("\n"); -} - -export function createDrizzleSchemaAdapter( - id: string, - dialect: DrizzleDialect, -): DatabaseSchemaAdapter { - return { - id, - generateSchemaFiles({ storage }) { - return [ - { - path: "hackkit.ts", - content: compileDrizzleSchema(storage, dialect), - }, - ]; - }, - }; -} diff --git a/packages/db-drizzle/src/sqlite.ts b/packages/db-drizzle/src/sqlite.ts deleted file mode 100644 index f90c3f82..00000000 --- a/packages/db-drizzle/src/sqlite.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { AnyField, PersistentModel } from "@hackkit/core"; -import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; -import { createDrizzleAdapterFactory } from "./runtime"; -import { createDrizzleSchemaAdapter } from "./schema"; - -export function createDrizzleSqliteSchemaAdapter() { - return createDrizzleSchemaAdapter("drizzle-sqlite", "sqlite"); -} - -function toColumn(name: string, field: AnyField) { - let column: any; - switch (field.kind) { - case "string": - case "enum": - column = text(name); - break; - case "integer": - column = integer(name); - break; - case "number": - column = real(name); - break; - case "boolean": - column = integer(name, { mode: "boolean" }); - break; - case "date": - column = integer(name, { mode: "timestamp" }); - break; - case "json": - column = text(name, { mode: "json" }); - break; - } - if (field.isPrimaryKey) column = column.primaryKey(); - if (field.isUnique) column = column.unique(); - if (!field.isOptional) column = column.notNull(); - return column; -} - -function createTable(model: PersistentModel) { - return sqliteTable( - model.key.replaceAll(".", "_"), - Object.fromEntries( - Object.entries(model.schema.fields).map(([name, field]) => [ - name, - toColumn(name, field), - ]), - ), - ) as never; -} - -export function createDrizzleSqliteAdapter( - database: unknown | (() => unknown), -) { - return createDrizzleAdapterFactory( - database, - createTable, - createDrizzleSqliteSchemaAdapter(), - ); -} - -export { toDrizzleTableExportName, toDrizzleTableName } from "./schema"; diff --git a/packages/db-drizzle/test-fixtures/postgres/drizzle.config.ts b/packages/db-drizzle/test-fixtures/postgres/drizzle.config.ts deleted file mode 100644 index 80dafbbc..00000000 --- a/packages/db-drizzle/test-fixtures/postgres/drizzle.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { defineConfig } from "drizzle-kit"; - -export default defineConfig({ - schema: "./test-fixtures/postgres/schema.ts", - out: "./test-fixtures/postgres/migrations", - dialect: "postgresql", - dbCredentials: { - url: - process.env.POSTGRES_TEST_URL ?? - "postgres://postgres:postgres@localhost:5432/hackkit", - }, -}); diff --git a/packages/db-drizzle/test-fixtures/postgres/migrations/0000_init.sql b/packages/db-drizzle/test-fixtures/postgres/migrations/0000_init.sql deleted file mode 100644 index cb5587fc..00000000 --- a/packages/db-drizzle/test-fixtures/postgres/migrations/0000_init.sql +++ /dev/null @@ -1,7 +0,0 @@ -CREATE TABLE "sample_record" ( - "id" text PRIMARY KEY NOT NULL, - "active" boolean NOT NULL, - "score" double precision NOT NULL, - "metadata" jsonb NOT NULL, - "createdAt" timestamp with time zone NOT NULL -); diff --git a/packages/db-drizzle/test-fixtures/postgres/migrations/meta/0000_snapshot.json b/packages/db-drizzle/test-fixtures/postgres/migrations/meta/0000_snapshot.json deleted file mode 100644 index e2910303..00000000 --- a/packages/db-drizzle/test-fixtures/postgres/migrations/meta/0000_snapshot.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "id": "2d001932-8b73-4315-8adb-7edaf9821725", - "prevId": "00000000-0000-0000-0000-000000000000", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.sample_record": { - "name": "sample_record", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "active": { - "name": "active", - "type": "boolean", - "primaryKey": false, - "notNull": true - }, - "score": { - "name": "score", - "type": "double precision", - "primaryKey": false, - "notNull": true - }, - "metadata": { - "name": "metadata", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "createdAt": { - "name": "createdAt", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file diff --git a/packages/db-drizzle/test-fixtures/postgres/migrations/meta/_journal.json b/packages/db-drizzle/test-fixtures/postgres/migrations/meta/_journal.json deleted file mode 100644 index 8cbe8452..00000000 --- a/packages/db-drizzle/test-fixtures/postgres/migrations/meta/_journal.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1784085219647, - "tag": "0000_init", - "breakpoints": true - } - ] -} \ No newline at end of file diff --git a/packages/db-drizzle/test-fixtures/postgres/schema.ts b/packages/db-drizzle/test-fixtures/postgres/schema.ts deleted file mode 100644 index fb809a36..00000000 --- a/packages/db-drizzle/test-fixtures/postgres/schema.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { boolean, doublePrecision, index, integer, jsonb, pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core"; - -export const sampleRecord = pgTable("sample_record", { - id: text("id").primaryKey().notNull(), - active: boolean("active").notNull(), - score: doublePrecision("score").notNull(), - metadata: jsonb("metadata").notNull(), - createdAt: timestamp("createdAt", { withTimezone: true, mode: "date" }).notNull() -}); diff --git a/packages/db-drizzle/tsconfig.json b/packages/db-drizzle/tsconfig.json deleted file mode 100644 index 16e73196..00000000 --- a/packages/db-drizzle/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": "../tsconfig/base.json", - "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "Bundler", - "rootDir": "src", - "outDir": "dist", - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "noEmit": false - }, - "include": ["src/**/*.ts"], - "exclude": ["dist", "node_modules"] -} diff --git a/packages/next/README.md b/packages/next/README.md index 230c469f..fbe4924d 100644 --- a/packages/next/README.md +++ b/packages/next/README.md @@ -1,117 +1,42 @@ # `@hackkit/next` -Next.js integration for HackKit Web Apps. This package is the single composition root for runtime setup, page guards, Core mutations, and HackKit UI provider actions. +Next.js integration for Core authentication, request scopes, page guards, server actions, and HackKit UI. -Canonical walkthrough: [`docs/guides/next-integration.md`](../../docs/guides/next-integration.md). Reference app: [`apps/web`](../../apps/web). +## Runtime setup -## Integration path +`createHackkitRuntimeFromConfig({ config, afterCurrentUser? })` returns a host with a shared `core` promise and a `getRuntime` function. Register the function with `setHackkitRuntime(host.getRuntime)`. Use [the reference app runtime](../../apps/web/lib/runtime.ts) as the integration example. -Wire a HackKit Web App in four steps. Do not rebuild guards or action maps in the app. +The host initializes one Core ORM and Better Auth instance. Each request or action resolves its session from Next headers and creates a fresh EntityManager and domain API scope. React caches the runtime only within a server render. Never store a request runtime, actor, EntityManager, or settings cache in an application singleton. -### 1. Create and register the runtime +`config.database` contains native MikroORM driver options. `config.auth` contains Better Auth options except `database`, which Core owns. `@hackkit/next` adds Better Auth's cookie integration; [the app auth route](../../apps/web/app/api/auth/[...all]/route.ts) delegates to Core's auth handler. -```ts -// lib/runtime.ts -import "server-only"; -import { - createHackkitRuntimeFromConfig, - setHackkitRuntime, -} from "@hackkit/next"; +`getCurrentUser` reads the existing HackKit profile and authoritative auth email. It does not create or overwrite profiles. `afterCurrentUser` optionally performs app-specific work after a profile is loaded. -const runtimePromise = createHackkitRuntimeFromConfig({ - config: appConfig, - auth: authAdapter, - afterCurrentUser: async (user, hackkit) => { - // optional app-specific work (e.g. owner allowlist) - }, -}); +## UI and routes -setHackkitRuntime(runtimePromise); +Pass `hackKitUIActions` to `HackKitUIProvider`. Named server actions resolve the registered runtime and call its mutations. Keep route overrides serializable; custom route-builder functions belong in a client navigation adapter. -export async function getRuntime() { - return runtimePromise; -} +Use `runtime.pageGuards` in layouts. Common guards include `requireOnboardingAccess`, `requireParticipantAccess`, `requireApprovalPendingAccess`, `requirePermission`, and `requireNotBanned`. -export async function getPageGuards() { - return (await getRuntime()).pageGuards; -} -``` - -`appConfig.database` contains the explicit `DatabaseAdapterFactory`; the Next integration does not assume or wrap a database dialect. - -`createHackkitRuntime` / `createHackkitRuntimeFromConfig` owns: - -- HackKit Core init -- `mutations` via `createHackKitMutations` -- `pageGuards` via `createPageGuards` (with runtime `getCurrentUser` / settings cache) -- Auth helpers (`getAuthId`, `getCurrentUser`, `getSettingValue`) - -### 2. Pass `hackKitUIActions` to HackKit UI - -```tsx -// app/providers.tsx -import { hackKitUIActions } from "@hackkit/next"; -import { HackKitUIProvider } from "@hackkit/ui"; - -export function Providers({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} -``` - -Do not hand-maintain a local Core action map. Named server actions live inside `@hackkit/next` and delegate to `runtime.mutations`. - -Do not pass route maps that contain builder functions from a Server Component — they are not serializable. Omit `routes` to use client-side defaults, pass only string overrides, or supply a client `navigation` adapter for custom admin builders. Server-only helpers (for example onboarding step links) may still import `DEFAULT_HACKKIT_UI_ROUTES` / an app route map. - -### 3. Protect routes with runtime-owned page guards - -```tsx -// app/onboarding/layout.tsx -import { getPageGuards } from "@/lib/runtime"; - -export default async function OnboardingLayout({ children }) { - const guards = await getPageGuards(); - await guards.requireOnboardingAccess(); - return children; -} -``` - -Common guards: `requireOnboardingAccess`, `requireParticipantAccess`, `requireApprovalPendingAccess`, `requirePermission`, `requireNotBanned`, `requireHackerRegistrationOpenForNewHacker`. - -Do not call `createPageGuards` in the app — use `runtime.pageGuards` / `getPageGuards()`. - -### 4. Sync plugin actions separately - -Plugin server actions stay generated by `hackkit plugin sync` (for example `app/hackkit-plugin-actions.ts`). They use the same registered runtime via `getHackkitRuntime()` / `getRuntime()`. +Plugin actions are generated separately by `hackkit plugin sync`. They use each plugin's `/actions` entrypoint and resolve the same request runtime. Server plugin entrypoints remain usable by the CLI without importing Next. ## Public API -| Export | Role | -| --------------------------------------------------------- | ----------------------------------------------------- | -| `createHackkitRuntime` / `createHackkitRuntimeFromConfig` | Composition root | -| `setHackkitRuntime` / `getHackkitRuntime` | Module singleton for server actions | -| `hackKitUIActions` | Provider action map (`satisfies HackKitUIActions`) | -| `createHackKitMutations` | Core → UI mutation bridge (used by the runtime) | -| `createPageGuards` | Low-level guard factory (prefer runtime-owned guards) | -| `actionSuccess` / `actionFailure` | Shared action result helpers | +| Export | Role | +| ----------------------------------------- | ---------------------------------------------------------- | +| `createHackkitRuntimeFromConfig` | Initializes shared resources and a request runtime factory | +| `createHackkitRuntime` | Creates one request runtime from an initialized Core host | +| `setHackkitRuntime` / `getHackkitRuntime` | Registers and resolves the request factory | +| `hackKitUIActions` | UI provider action map | +| `createHackKitMutations` | Domain-to-UI mutation bridge used by runtime | +| `createPageGuards` | Guard factory used by runtime | +| `actionSuccess` / `actionFailure` | Shared action result helpers | ## Adding a Core mutation 1. Add the method to `HackKitUIActions` in `@hackkit/ui`. -2. Implement it in `createHackKitMutations` (`src/mutations.ts`). -3. Add a thin `"use server"` wrapper in `src/actions.ts`. -4. Include it in `hackKitUIActions` (`src/action-map.ts`). - -App-owned bridge files should not need changes. Package tests assert key parity across the UI contract, mutations, and provider map. - -## Verify +2. Implement it in `createHackKitMutations`. +3. Add a thin server action in `src/actions.ts`. +4. Include it in `hackKitUIActions` in `src/action-map.ts`. -```bash -pnpm --filter @hackkit/next test -pnpm --filter @hackkit/next typecheck -pnpm --filter web typecheck -pnpm --filter web build -``` +Package tests check action-map parity, guard behavior, session isolation, anonymous requests, and profile preservation. Run `pnpm --filter @hackkit/next test` and `pnpm --filter @hackkit/next typecheck` after building dependencies. diff --git a/packages/next/package.json b/packages/next/package.json index 594980c5..00f091fc 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -22,17 +22,19 @@ "dependencies": { "@hackkit/config": "workspace:*", "@hackkit/core": "workspace:*", - "@hackkit/ui": "workspace:*" + "@hackkit/ui": "workspace:*", + "better-auth": "1.7.6" }, "peerDependencies": { "next": ">=14", "react": ">=18" }, "devDependencies": { - "@types/node": "20.14.11", + "@types/node": "22.18.6", "next": "14.2.35", - "typescript": "5.5.3", - "vitest": "^3.2.4" + "typescript": "5.9.3", + "vitest": "^3.2.4", + "@types/react": "18.3.3" }, "license": "MIT", "private": true diff --git a/packages/next/src/__tests__/page-guards.test.ts b/packages/next/src/__tests__/page-guards.test.ts index 75dd8531..92cf4830 100644 --- a/packages/next/src/__tests__/page-guards.test.ts +++ b/packages/next/src/__tests__/page-guards.test.ts @@ -1,11 +1,13 @@ +import { + createTestHackkit, + createTestUser, + type TestHackkit, +} from "@hackkit/core/testing"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { CorePermission, CoreSetting, coreModels, - createHackkit, - createInMemoryDatabaseAdapterFromStorage, - createPluginRegistry, seedRoles, type HackKit, } from "@hackkit/core"; @@ -28,28 +30,9 @@ vi.mock("next/navigation", () => ({ import { createPageGuards } from "../page-guards"; -function createTestHackkit() { - const registry = createPluginRegistry(); - let timestamp = 0; - const now = () => - new Date( - `2026-05-24T12:00:${String(timestamp++).padStart(2, "0")}.000Z`, - ); - let counter = 0; - const id = () => `id-${++counter}`; - const db = createInMemoryDatabaseAdapterFromStorage( - registry.storage, - now, - id, - ); - return Object.assign(createHackkit({ database: db, clock: now, id }), { - database: db, - }); -} - -async function seedOwner(hackkit: ReturnType) { +async function seedOwner(hackkit: TestHackkit) { await seedRoles({ - database: hackkit.database, + em: hackkit.em, roles: [ { id: "core.owner", @@ -59,20 +42,24 @@ async function seedOwner(hackkit: ReturnType) { }, ], }); - await hackkit.users.ensureUser({ + await createTestUser(hackkit, { authId: "admin-auth", email: "admin@example.com", firstName: "Ad", lastName: "Min", }); - await hackkit.database.update( + await hackkit.em.nativeUpdate( coreModels.user, { authId: "admin-auth" }, { roleId: "core.owner", updatedAt: new Date() }, ); } -async function setSetting(hackkit: HackKit, key: CoreSetting, value: boolean) { +async function setSetting( + hackkit: TestHackkit, + key: CoreSetting, + value: boolean, +) { await hackkit.settings.set({ actorAuthId: "admin-auth", key, @@ -80,8 +67,12 @@ async function setSetting(hackkit: HackKit, key: CoreSetting, value: boolean) { }); } -async function ensureUser(hackkit: HackKit, authId: string, hackTag?: string) { - await hackkit.users.ensureUser({ +async function createFixtureUser( + hackkit: TestHackkit, + authId: string, + hackTag?: string, +) { + await createTestUser(hackkit, { authId, email: `${authId}@example.com`, firstName: authId, @@ -96,7 +87,7 @@ async function ensureUser(hackkit: HackKit, authId: string, hackTag?: string) { return (await hackkit.users.getUser(authId))!; } -async function completeUserData(hackkit: HackKit, authId: string) { +async function completeUserData(hackkit: TestHackkit, authId: string) { await hackkit.userData.completeUserData({ authId, age: 20, @@ -111,7 +102,7 @@ async function completeUserData(hackkit: HackKit, authId: string) { }); } -async function registerHacker(hackkit: HackKit, authId: string) { +async function registerHacker(hackkit: TestHackkit, authId: string) { await hackkit.hackers.registerHacker({ authId, university: "Test U", @@ -123,22 +114,22 @@ async function registerHacker(hackkit: HackKit, authId: string) { } async function seedCompletedUnapprovedHacker( - hackkit: HackKit, + hackkit: TestHackkit, authId: string, hackTag: string, ) { await setSetting(hackkit, CoreSetting.RequireApproval, true); - await ensureUser(hackkit, authId, hackTag); + await createFixtureUser(hackkit, authId, hackTag); await completeUserData(hackkit, authId); await registerHacker(hackkit, authId); } async function seedApprovedParticipant( - hackkit: HackKit, + hackkit: TestHackkit, authId: string, hackTag: string, ) { - await ensureUser(hackkit, authId, hackTag); + await createFixtureUser(hackkit, authId, hackTag); await completeUserData(hackkit, authId); await registerHacker(hackkit, authId); } @@ -148,17 +139,17 @@ function expectRedirect(promise: Promise, to: string) { } describe("createPageGuards", () => { - let hackkit: HackKit; + let hackkit: TestHackkit; beforeEach(async () => { redirectMock.mockClear(); notFoundMock.mockClear(); - hackkit = createTestHackkit(); + hackkit = await createTestHackkit(); await seedOwner(hackkit); }); it("redirects incomplete onboarding to the next step", async () => { - await ensureUser(hackkit, "user-1", "alice"); + await createFixtureUser(hackkit, "user-1", "alice"); const guards = createPageGuards(hackkit, async () => "user-1"); await expectRedirect( @@ -191,7 +182,7 @@ describe("createPageGuards", () => { }); it("redirects banned users to suspended", async () => { - await ensureUser(hackkit, "user-4", "dan"); + await createFixtureUser(hackkit, "user-4", "dan"); await hackkit.users.banUser({ actorAuthId: "admin-auth", targetAuthId: "user-4", @@ -203,7 +194,7 @@ describe("createPageGuards", () => { }); it("redirects new hackers when registration is closed", async () => { - await ensureUser(hackkit, "user-5", "erin"); + await createFixtureUser(hackkit, "user-5", "erin"); await completeUserData(hackkit, "user-5"); await setSetting(hackkit, CoreSetting.RegistrationOpen, false); diff --git a/packages/next/src/__tests__/runtime.test.ts b/packages/next/src/__tests__/runtime.test.ts new file mode 100644 index 00000000..fb130355 --- /dev/null +++ b/packages/next/src/__tests__/runtime.test.ts @@ -0,0 +1,66 @@ +import { expect, it, vi } from "vitest"; +import { createTestHackkit } from "@hackkit/core/testing"; + +const requestHeaders = vi.hoisted(() => vi.fn<() => Headers>()); +vi.mock("next/headers", () => ({ headers: requestHeaders })); +vi.mock("next/navigation", () => ({ + redirect: (path: string): never => { + throw new Error(`REDIRECT:${path}`); + }, +})); + +import { createHackkitRuntime } from "../runtime"; + +it("binds concurrent requests to separate users and never overwrites edited profiles", async () => { + const { runtime: core } = await createTestHackkit(); + const cookies: string[] = []; + for (const name of ["Alice", "Bob"]) { + const response = await core.auth.api.signUpEmail({ + body: { + name, + email: `${name.toLowerCase()}@example.com`, + password: "a sufficiently long password", + }, + asResponse: true, + }); + expect(response.status).toBe(200); + cookies.push(response.headers.get("set-cookie")!); + } + requestHeaders.mockReturnValueOnce(new Headers({ cookie: cookies[0] })); + requestHeaders.mockReturnValueOnce(new Headers({ cookie: cookies[1] })); + const [alice, bob] = await Promise.all([ + createHackkitRuntime({ core }), + createHackkitRuntime({ core }), + ]); + const aliceId = await alice.getAuthId(); + const bobId = await bob.getAuthId(); + expect(aliceId).not.toBe(bobId); + expect(alice.hackkit).not.toBe(bob.hackkit); + await alice.hackkit.users.updateProfile({ + authId: aliceId, + firstName: "Alicia", + bio: "Saved profile", + }); + expect(await bob.getCurrentUser()).toMatchObject({ + authId: bobId, + firstName: "Bob", + }); + requestHeaders.mockReturnValueOnce(new Headers({ cookie: cookies[0] })); + const nextRequest = await createHackkitRuntime({ core }); + const profile = await nextRequest.getCurrentUser(); + expect(profile).toMatchObject({ + authId: aliceId, + firstName: "Alicia", + bio: "Saved profile", + }); + expect(Object.getPrototypeOf(profile)).toBe(Object.prototype); + expect(profile).not.toHaveProperty("password"); + expect(profile).not.toHaveProperty("session"); +}); + +it("redirects an anonymous authenticated operation without creating an identity", async () => { + const { runtime: core } = await createTestHackkit(); + requestHeaders.mockReturnValueOnce(new Headers()); + const request = await createHackkitRuntime({ core }); + await expect(request.getCurrentUser()).rejects.toThrow("REDIRECT:/sign-in"); +}); diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index 9669fa9a..8f59c32c 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -8,6 +8,7 @@ export type { CreateHackkitRuntimeFromConfigOptions, CreateHackkitRuntimeOptions, HackkitRuntime, + HackkitRuntimeHost, } from "./runtime"; export { actionFailure, actionSuccess } from "@hackkit/ui/actions"; export type { HackKitActionResult } from "@hackkit/ui/actions"; diff --git a/packages/next/src/mutations.ts b/packages/next/src/mutations.ts index b1779acc..cfc7a599 100644 --- a/packages/next/src/mutations.ts +++ b/packages/next/src/mutations.ts @@ -52,10 +52,11 @@ export type CreateHackKitMutationsOptions = { hackkit: HackKit; getAuthId: () => Promise; getSettingValue: (key: SettingKey) => Promise; - invalidateSettingsCache?: () => void; }; -export function createHackKitMutations(runtime: HackkitRuntime): HackKitUIActions; +export function createHackKitMutations( + runtime: HackkitRuntime, +): HackKitUIActions; export function createHackKitMutations( options: CreateHackKitMutationsOptions, ): HackKitUIActions; @@ -67,15 +68,20 @@ export function createHackKitMutations( hackkit: optionsOrRuntime.hackkit, getAuthId: optionsOrRuntime.getAuthId, getSettingValue: optionsOrRuntime.getSettingValue, - invalidateSettingsCache: optionsOrRuntime.invalidateSettingsCache, } : optionsOrRuntime; - const { hackkit, getAuthId, getSettingValue, invalidateSettingsCache } = options; + const { hackkit, getAuthId, getSettingValue } = options; const now = () => new Date(); async function resolveTargetFromQr(rawQr: string) { - const eventPassQrTtlMs = await getSettingValue(CoreSetting.EventPassQrTtlMs); - return resolveEventPassTargetAuthId(rawQr, now(), Number(eventPassQrTtlMs)); + const eventPassQrTtlMs = await getSettingValue( + CoreSetting.EventPassQrTtlMs, + ); + return resolveEventPassTargetAuthId( + rawQr, + now(), + Number(eventPassQrTtlMs), + ); } return { @@ -127,7 +133,10 @@ export function createHackKitMutations( }); return actionSuccess(); } catch (error) { - return actionFailure(error, "Could not complete hacker registration."); + return actionFailure( + error, + "Could not complete hacker registration.", + ); } }, @@ -200,7 +209,10 @@ export function createHackKitMutations( return actionSuccess({ user, priorScans }); } catch (error) { - return actionFailure(error, "Could not read Event Pass QR code."); + return actionFailure( + error, + "Could not read Event Pass QR code.", + ); } }, @@ -246,8 +258,10 @@ export function createHackKitMutations( async setSettings(values) { try { const actorAuthId = await getAuthId(); - const settings = await hackkit.settings.setMany({ actorAuthId, values }); - invalidateSettingsCache?.(); + const settings = await hackkit.settings.setMany({ + actorAuthId, + values, + }); return actionSuccess(settings); } catch (error) { return actionFailure(error, "Could not save settings."); @@ -257,8 +271,10 @@ export function createHackKitMutations( async resetSetting(key) { try { const actorAuthId = await getAuthId(); - const setting = await hackkit.settings.reset({ actorAuthId, key }); - invalidateSettingsCache?.(); + const setting = await hackkit.settings.reset({ + actorAuthId, + key, + }); return actionSuccess(setting); } catch (error) { return actionFailure(error, "Could not reset setting."); @@ -291,7 +307,10 @@ export function createHackKitMutations( async cancelRsvp(targetAuthId: string) { try { const actorAuthId = await getAuthId(); - const rsvp = await hackkit.rsvp.cancel({ actorAuthId, targetAuthId }); + const rsvp = await hackkit.rsvp.cancel({ + actorAuthId, + targetAuthId, + }); return actionSuccess(rsvp); } catch (error) { return actionFailure(error, "Could not cancel RSVP."); @@ -314,7 +333,10 @@ export function createHackKitMutations( async promoteRsvp(targetAuthId?: string) { try { const actorAuthId = await getAuthId(); - const rsvp = await hackkit.rsvp.promote({ actorAuthId, targetAuthId }); + const rsvp = await hackkit.rsvp.promote({ + actorAuthId, + targetAuthId, + }); return actionSuccess(rsvp); } catch (error) { return actionFailure(error, "Could not promote RSVP."); diff --git a/packages/next/src/runtime.ts b/packages/next/src/runtime.ts index e8ff3f00..ecc0b829 100644 --- a/packages/next/src/runtime.ts +++ b/packages/next/src/runtime.ts @@ -1,41 +1,28 @@ import { - createHackkit, - type AuthAdapter, - type DatabaseAdapterInput, + initializeHackkit, + HackKitError, type HackKit, - type HackKitPlugin, + type HackKitRuntime as CoreRuntime, type SettingKey, type SettingValue, type User, } from "@hackkit/core"; import { resolveHackkitConfig, type HackkitConfig } from "@hackkit/config"; -import type { EventTypesInput, UserDataOptionsInput } from "@hackkit/core"; -import type { GroupsInput } from "@hackkit/core"; -import type { HackKitLoggerOptions } from "@hackkit/core"; import type { HackKitUIActions } from "@hackkit/ui"; +import { headers } from "next/headers"; import { redirect } from "next/navigation"; +import { nextCookies } from "better-auth/next-js"; +import { cache } from "react"; import { createHackKitMutations } from "./mutations"; import { createPageGuards, type PageGuards } from "./page-guards"; export type CreateHackkitRuntimeOptions = { - database: DatabaseAdapterInput; - auth: AuthAdapter; - plugins?: readonly HackKitPlugin[]; - userDataOptions?: UserDataOptionsInput; - eventTypes?: EventTypesInput; - groups?: GroupsInput; - logger?: HackKitLoggerOptions; - defaultCompetitorRoleId?: string; - /** - * App-specific work after the runtime resolves the current user. - * Runs for both `runtime.getCurrentUser` and the runtime-owned page guards. - */ + core: CoreRuntime; afterCurrentUser?: (user: User, hackkit: HackKit) => Promise; }; export type CreateHackkitRuntimeFromConfigOptions = { config: HackkitConfig; - auth: AuthAdapter; afterCurrentUser?: CreateHackkitRuntimeOptions["afterCurrentUser"]; }; @@ -43,72 +30,50 @@ export type HackkitRuntime = { hackkit: HackKit; mutations: HackKitUIActions; pageGuards: PageGuards; - getAuthId: () => Promise; - getCurrentUser: () => Promise; - getSettingValue: (key: SettingKey) => Promise; - invalidateSettingsCache: () => void; + getAuthId(): Promise; + getCurrentUser(): Promise; + getSettingValue(key: SettingKey): Promise; }; +export type HackkitRuntimeHost = { + core: Promise; + getRuntime(): Promise; +}; + +/** Bind domain operations to this request's session and fresh EntityManager. */ export async function createHackkitRuntime( options: CreateHackkitRuntimeOptions, ): Promise { - const hackkit = createHackkit({ - database: options.database, - plugins: options.plugins, - userDataOptions: options.userDataOptions, - eventTypes: options.eventTypes, - groups: options.groups, - logger: options.logger, - defaultCompetitorRoleId: options.defaultCompetitorRoleId, + const session = await options.core.auth.api.getSession({ + headers: await headers(), }); - - async function requireSession() { - const session = await options.auth.getSession(); - if (!session) redirect("/sign-in"); - return session; - } + const { hackkit } = options.core.createScope(session?.user.id); async function getAuthId(): Promise { - return options.auth.toAuthId(await requireSession()); + if (!session) redirect("/sign-in"); + return session.user.id; } async function getCurrentUser(): Promise { - const session = await requireSession(); - const identity = options.auth.getIdentity(session); - const user = await hackkit.users.ensureUser({ - authId: options.auth.toAuthId(session), - ...identity, - }); - if (options.afterCurrentUser) { - await options.afterCurrentUser(user, hackkit); - } + const user = await hackkit.users.getUser(await getAuthId()); + if (!user) + throw new HackKitError("NOT_FOUND", "User profile not found."); + await options.afterCurrentUser?.(user, hackkit); return user; } - const settingsCache = new Map>(); - function invalidateSettingsCache() { - settingsCache.clear(); + function getSettingValue(key: SettingKey) { + return hackkit.settings.getValue(key); } - function getSettingValue(key: SettingKey): Promise { - const cached = settingsCache.get(key); - if (cached) return cached; - const value = hackkit.settings.getValue(key); - settingsCache.set(key, value); - return value; - } - const mutations = createHackKitMutations({ hackkit, getAuthId, getSettingValue, - invalidateSettingsCache, }); - const pageGuards = createPageGuards(hackkit, getAuthId, { getCurrentUser, getSettingValue, }); - return { hackkit, mutations, @@ -116,38 +81,43 @@ export async function createHackkitRuntime( getAuthId, getCurrentUser, getSettingValue, - invalidateSettingsCache, }; } +/** Initialize shared resources once; React caches only within a server render. */ export function createHackkitRuntimeFromConfig( options: CreateHackkitRuntimeFromConfigOptions, -): Promise { +): HackkitRuntimeHost { const config = resolveHackkitConfig(options.config); - return createHackkitRuntime({ - database: config.database, - auth: options.auth, - plugins: config.plugins, - userDataOptions: config.userDataOptions, - eventTypes: config.eventTypes, - groups: config.groups, - logger: config.logger, - defaultCompetitorRoleId: config.defaultCompetitorRoleId, - afterCurrentUser: options.afterCurrentUser, + const core = initializeHackkit({ + ...config, + auth: { + ...config.auth, + plugins: [...(config.auth.plugins ?? []), nextCookies()], + }, }); + const getRuntime = cache(async () => + createHackkitRuntime({ + core: await core, + afterCurrentUser: options.afterCurrentUser, + }), + ); + return { core, getRuntime }; } -let runtimePromise: Promise | null = null; +let runtimeProvider: (() => Promise) | undefined; -export function setHackkitRuntime(promise: Promise): void { - runtimePromise = promise; +/** Register a factory, never a request's runtime or EntityManager. */ +export function setHackkitRuntime( + provider: () => Promise, +): void { + runtimeProvider = provider; } export async function getHackkitRuntime(): Promise { - if (!runtimePromise) { + if (!runtimeProvider) throw new Error( "HackKit runtime is not initialized. Call setHackkitRuntime() from your app runtime module.", ); - } - return runtimePromise; + return runtimeProvider(); } diff --git a/packages/plugin-discord/package.json b/packages/plugin-discord/package.json index 6419ef0a..0150e8a8 100644 --- a/packages/plugin-discord/package.json +++ b/packages/plugin-discord/package.json @@ -8,7 +8,7 @@ "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./src/index.ts", + "import": "./dist/index.js", "default": "./dist/index.js" }, "./app/discord/page": { @@ -19,7 +19,12 @@ "types": "./app/discord/verify/page.tsx", "default": "./app/discord/verify/page.tsx" }, - "./app/*": "./app/*" + "./app/*": "./app/*", + "./actions": { + "types": "./dist/actions.d.ts", + "import": "./dist/actions.js", + "default": "./dist/actions.js" + } }, "files": [ "dist", @@ -36,7 +41,8 @@ "@hackkit/next": "workspace:*", "@hackkit/ui": "workspace:*", "sonner": "^1.5.0", - "zod": "^3.25.67" + "zod": "^3.25.67", + "@mikro-orm/core": "7.2.1" }, "peerDependencies": { "next": ">=14", @@ -46,7 +52,7 @@ "devDependencies": { "@types/react": "18.3.3", "@types/react-dom": "18.3.0", - "typescript": "5.5.3", + "typescript": "5.9.3", "vitest": "^3.2.4" }, "license": "MIT", diff --git a/packages/plugin-discord/src/__tests__/discord.test.ts b/packages/plugin-discord/src/__tests__/discord.test.ts index 2a44eb3e..f3b68870 100644 --- a/packages/plugin-discord/src/__tests__/discord.test.ts +++ b/packages/plugin-discord/src/__tests__/discord.test.ts @@ -1,17 +1,17 @@ -import { describe, expect, it } from "vitest"; import { - createHackkit, - createInMemoryDatabaseAdapterFromStorage, - createPluginRegistry, - type HackKitPlugin, -} from "@hackkit/core"; + createTestHackkit, + createTestUser, + type TestHackkit, +} from "@hackkit/core/testing"; +import { describe, expect, it } from "vitest"; + import { discordPlugin, type DiscordApi, type DiscordRoleSyncInput, -} from "../index"; +} from "../index.js"; -function createDiscordHackkit() { +async function createDiscordHackkit() { const syncInputs: DiscordRoleSyncInput[] = []; const plugin = discordPlugin({ guildId: "guild-1", @@ -19,35 +19,26 @@ function createDiscordHackkit() { participantRole: { id: "participant-role" }, roleSyncProvider: { async syncRoles(input) { + expect(hackkit.em.isInTransaction()).toBe(false); syncInputs.push(input); }, }, }); - const registry = createPluginRegistry([plugin as unknown as HackKitPlugin]); - const now = () => new Date("2026-05-24T12:00:00.000Z"); - let counter = 0; - const id = () => `id-${++counter}`; - const db = createInMemoryDatabaseAdapterFromStorage( - registry.storage, - now, - id, - ); - const hackkit = createHackkit({ - database: db, - plugins: [plugin as unknown as HackKitPlugin], - clock: now, - id, + const hackkit = await createTestHackkit({ + plugins: [plugin], groups: [{ id: "alpha", label: "Alpha", discordRoleId: "alpha-role" }], }); return { hackkit, - discord: hackkit.plugins.discord as unknown as DiscordApi, + discord: hackkit.plugins.discord, syncInputs, }; } -async function seedApprovedHacker(hackkit: ReturnType) { - await hackkit.users.ensureUser({ +async function seedApprovedHacker( + hackkit: TestHackkit]>, +) { + await createTestUser(hackkit, { authId: "hacker-auth", email: "hacker@example.com", firstName: "Hazel", @@ -78,7 +69,7 @@ async function seedApprovedHacker(hackkit: ReturnType) { describe("discord plugin", () => { it("links a pending verification and syncs participant and group roles", async () => { - const { hackkit, discord, syncInputs } = createDiscordHackkit(); + const { hackkit, discord, syncInputs } = await createDiscordHackkit(); await seedApprovedHacker(hackkit); const verification = await discord.createVerification({ @@ -114,3 +105,34 @@ describe("discord plugin", () => { }); }); }); + +it("consumes a verification code once across concurrent requests", async () => { + const { hackkit, discord, syncInputs } = await createDiscordHackkit(); + await seedApprovedHacker(hackkit); + await discord.createVerification({ + code: "race", + discordUserId: "discord-race", + username: "racer", + }); + const results = await Promise.allSettled( + [1, 2].map(() => + hackkit.runtime + .createScope() + .hackkit.plugins.discord.confirmVerification({ + authId: "hacker-auth", + code: "race", + }), + ), + ); + expect( + results.filter((result) => result.status === "fulfilled"), + ).toHaveLength(1); + expect( + results.find((result) => result.status === "rejected"), + ).toMatchObject({ reason: { code: "INVALID_OPERATION" } }); + expect(syncInputs).toHaveLength(1); + expect(await discord.getVerification("race")).toMatchObject({ + status: "accepted", + authId: "hacker-auth", + }); +}); diff --git a/packages/plugin-discord/src/actions.ts b/packages/plugin-discord/src/actions.ts index 99c4264f..b1f6069f 100644 --- a/packages/plugin-discord/src/actions.ts +++ b/packages/plugin-discord/src/actions.ts @@ -1,6 +1,6 @@ import type { HackkitRuntime } from "@hackkit/next"; import { actionFailure, actionSuccess } from "@hackkit/next"; -import type { DiscordApi } from "./api"; +import type { DiscordApi } from "./api.js"; export type ConfirmDiscordVerificationInput = { code: string; @@ -10,7 +10,9 @@ export function createDiscordActions(runtime: HackkitRuntime) { const discord = runtime.hackkit.plugins.discord as unknown as DiscordApi; return { - async confirmDiscordVerification(values: ConfirmDiscordVerificationInput) { + async confirmDiscordVerification( + values: ConfirmDiscordVerificationInput, + ) { try { const authId = await runtime.getAuthId(); const member = await discord.confirmVerification({ diff --git a/packages/plugin-discord/src/api.ts b/packages/plugin-discord/src/api.ts index f479dcd6..26c1bc00 100644 --- a/packages/plugin-discord/src/api.ts +++ b/packages/plugin-discord/src/api.ts @@ -1,6 +1,8 @@ +import { wrap } from "@mikro-orm/core"; import { coreModels, HackKitError, + withOperationLock, type AuthId, type HackKitPluginContext, type HackkitGroup, @@ -10,7 +12,7 @@ import { type DiscordMember, type DiscordRoleSyncAttempt, type DiscordVerification, -} from "./models"; +} from "./models.js"; export type DiscordRoleRef = { id?: string; @@ -27,7 +29,9 @@ export type DiscordRoleSyncInput = { }; export type DiscordRoleSyncProvider = { - syncRoles(input: DiscordRoleSyncInput): Promise<{ externalId?: string } | void>; + syncRoles( + input: DiscordRoleSyncInput, + ): Promise<{ externalId?: string } | void>; }; export type DiscordPluginOptions = { @@ -68,7 +72,8 @@ function addMs(date: Date, ms: number): Date { function roleRefsEqual(left: DiscordRoleRef, right: DiscordRoleRef): boolean { return Boolean( - (left.id && left.id === right.id) || (left.name && left.name === right.name), + (left.id && left.id === right.id) || + (left.name && left.name === right.name), ); } @@ -105,21 +110,27 @@ export function createDiscordApi( context: HackKitPluginContext, options: DiscordPluginOptions, ) { - const { database } = context; + const { em } = context; const ttlMs = options.verificationTtlMs ?? defaultVerificationTtlMs; async function getMember(authId: AuthId): Promise { - return database.findOne(discordModels.member, { authId }); + const member = await em.findOne(discordModels.member, { authId }); + return member ? wrap(member).toObject() : null; } - async function buildRoleSyncPlan(authId: AuthId): Promise { + async function buildRoleSyncPlan( + authId: AuthId, + ): Promise { const [member, user, hacker] = await Promise.all([ getMember(authId), - database.findOne(coreModels.user, { authId }), - database.findOne(coreModels.hacker, { authId }), + em.findOne(coreModels.user, { authId }), + em.findOne(coreModels.hacker, { authId }), ]); if (!member) { - throw new HackKitError("NOT_FOUND", "Discord account is not linked."); + throw new HackKitError( + "NOT_FOUND", + "Discord account is not linked.", + ); } if (!user) { throw new HackKitError("NOT_FOUND", "User not found."); @@ -127,7 +138,8 @@ export function createDiscordApi( const roleRefs: DiscordRoleRef[] = []; appendRole(roleRefs, options.participantRole); - if (user.roleId) appendRole(roleRefs, options.roleMappings?.[user.roleId]); + if (user.roleId) + appendRole(roleRefs, options.roleMappings?.[user.roleId]); const assignedGroup = context.groups.find( (candidate) => candidate.id === hacker?.group, ); @@ -136,7 +148,9 @@ export function createDiscordApi( return { member, roleIds: roleRefs.flatMap((role) => (role.id ? [role.id] : [])), - roleNames: roleRefs.flatMap((role) => (role.name ? [role.name] : [])), + roleNames: roleRefs.flatMap((role) => + role.name ? [role.name] : [], + ), nickname: `${user.firstName} ${user.lastName}`.trim(), }; } @@ -146,15 +160,19 @@ export function createDiscordApi( status: DiscordRoleSyncAttempt["status"]; error?: string; }): Promise { - return database.insert(discordModels.roleSyncAttempt, { - authId: input.plan.member.authId, - discordUserId: input.plan.member.discordUserId, - guildId: input.plan.member.guildId, - status: input.status, - roleIds: input.plan.roleIds, - roleNames: input.plan.roleNames, - error: input.error, - }); + { + const created = em.create(discordModels.roleSyncAttempt, { + authId: input.plan.member.authId, + discordUserId: input.plan.member.discordUserId, + guildId: input.plan.member.guildId, + status: input.status, + roleIds: input.plan.roleIds, + roleNames: input.plan.roleNames, + error: input.error, + }); + await em.flush(); + return wrap(created).toObject(); + } } return { @@ -163,10 +181,13 @@ export function createDiscordApi( ): Promise { const guildId = input.guildId ?? options.guildId; if (guildId !== options.guildId) { - throw new HackKitError("FORBIDDEN", "Discord guild is not configured."); + throw new HackKitError( + "FORBIDDEN", + "Discord guild is not configured.", + ); } const now = new Date(); - const verification = await database.insert(discordModels.verification, { + const verification = em.create(discordModels.verification, { code: input.code, discordUserId: input.discordUserId, guildId, @@ -176,14 +197,23 @@ export function createDiscordApi( expiresAt: addMs(now, ttlMs), createdAt: now, }); + await em.flush(); return { - ...verification, - verificationUrl: toVerificationUrl(options.verificationBaseUrl, input.code), + ...wrap(verification).toObject(), + verificationUrl: toVerificationUrl( + options.verificationBaseUrl, + input.code, + ), }; }, - async getVerification(code: string): Promise { - return database.findOne(discordModels.verification, { code }); + async getVerification( + code: string, + ): Promise { + const verification = await em.findOne(discordModels.verification, { + code, + }); + return verification ? wrap(verification).toObject() : null; }, getMember, @@ -191,83 +221,87 @@ export function createDiscordApi( async confirmVerification( input: ConfirmDiscordVerificationInput, ): Promise { - const verification = await database.findOne(discordModels.verification, { - code: input.code, - }); - if (!verification) { - throw new HackKitError("NOT_FOUND", "Discord verification was not found."); - } - if (verification.status !== "pending") { - throw new HackKitError( - "INVALID_OPERATION", - "Discord verification is no longer pending.", - ); - } - const now = new Date(); - if (isExpired(verification, now)) { - await database.update( - discordModels.verification, - { code: input.code }, - { status: "expired" }, - ); - throw new HackKitError("INVALID_OPERATION", "Discord verification expired."); - } - const [user, hacker] = await Promise.all([ - database.findOne(coreModels.user, { authId: input.authId }), - database.findOne(coreModels.hacker, { authId: input.authId }), - ]); - if (!user) throw new HackKitError("NOT_FOUND", "User not found."); - if (!user.isApproved || !hacker) { - throw new HackKitError( - "FORBIDDEN", - "Only approved Hackers can link Discord.", - ); - } - - const existingDiscordUser = await database.findOne(discordModels.member, { - discordUserId: verification.discordUserId, - }); - if ( - existingDiscordUser && - existingDiscordUser.authId !== input.authId - ) { - await database.update( - discordModels.verification, - { code: input.code }, - { status: "rejected" }, - ); - throw new HackKitError( - "CONFLICT", - "Discord account is already linked to another user.", - ); - } - - const existing = await getMember(input.authId); - const memberValue = { - authId: input.authId, - discordUserId: verification.discordUserId, - guildId: verification.guildId, - username: verification.username, - avatarHash: verification.avatarHash, - verifiedAt: existing?.verifiedAt ?? now, - updatedAt: now, - }; - const member = existing - ? ( - await database.update( - discordModels.member, - { authId: input.authId }, - memberValue, - ) - )[0] ?? existing - : await database.insert(discordModels.member, memberValue); - await database.update( - discordModels.verification, - { code: input.code }, - { status: "accepted", authId: input.authId, acceptedAt: now }, + const result = await withOperationLock( + em, + "discord.verification", + async () => { + const verification = await em.findOne( + discordModels.verification, + { code: input.code }, + ); + if (!verification) + throw new HackKitError( + "NOT_FOUND", + "Discord verification was not found.", + ); + if (verification.status !== "pending") + throw new HackKitError( + "INVALID_OPERATION", + "Discord verification is no longer pending.", + ); + const now = new Date(); + if (isExpired(verification, now)) { + verification.status = "expired"; + await em.flush(); + return { + error: new HackKitError( + "INVALID_OPERATION", + "Discord verification expired.", + ), + }; + } + const [user, hacker] = await Promise.all([ + em.findOne(coreModels.user, { authId: input.authId }), + em.findOne(coreModels.hacker, { authId: input.authId }), + ]); + if (!user) + throw new HackKitError("NOT_FOUND", "User not found."); + if (!user.isApproved || !hacker) + throw new HackKitError( + "FORBIDDEN", + "Only approved Hackers can link Discord.", + ); + const linked = await em.findOne(discordModels.member, { + discordUserId: verification.discordUserId, + }); + if (linked && linked.authId !== input.authId) { + verification.status = "rejected"; + await em.flush(); + return { + error: new HackKitError( + "CONFLICT", + "Discord account is already linked to another user.", + ), + }; + } + const existing = await em.findOne(discordModels.member, { + authId: input.authId, + }); + const values = { + authId: input.authId, + discordUserId: verification.discordUserId, + guildId: verification.guildId, + username: verification.username, + avatarHash: verification.avatarHash, + verifiedAt: existing?.verifiedAt ?? now, + updatedAt: now, + }; + const member = existing + ? em.assign(existing, values) + : em.create(discordModels.member, values); + em.assign(verification, { + status: "accepted", + authId: input.authId, + acceptedAt: now, + }); + await em.flush(); + return { member: wrap(member).toObject() }; + }, ); + if (result.error) throw result.error; + // The link and consumed code have committed before contacting Discord. await this.syncMemberRoles({ authId: input.authId }); - return member; + return result.member; }, async buildRoleSyncPlan(input: { @@ -292,17 +326,30 @@ export function createDiscordApi( roleNames: plan.roleNames, nickname: plan.nickname, }); - await database.update( - discordModels.member, - { authId: plan.member.authId }, - { lastRoleSyncAt: new Date(), updatedAt: new Date() }, - ); + { + const updatedRows = await em.find(discordModels.member, { + authId: plan.member.authId, + }); + for (const row of updatedRows) + em.assign( + row, + { + lastRoleSyncAt: new Date(), + updatedAt: new Date(), + }, + { ignoreUndefined: true }, + ); + await em.flush(); + } return recordRoleSyncAttempt({ plan, status: "synced" }); } catch (error) { return recordRoleSyncAttempt({ plan, status: "failed", - error: error instanceof Error ? error.message : "Role sync failed.", + error: + error instanceof Error + ? error.message + : "Role sync failed.", }); } }, diff --git a/packages/plugin-discord/src/index.ts b/packages/plugin-discord/src/index.ts index 74d2039c..c5930546 100644 --- a/packages/plugin-discord/src/index.ts +++ b/packages/plugin-discord/src/index.ts @@ -1,9 +1,6 @@ import type { HackKitPlugin } from "@hackkit/core"; -import { - createDiscordApi, - type DiscordPluginOptions, -} from "./api"; -import { discordModels } from "./models"; +import { createDiscordApi, type DiscordPluginOptions } from "./api.js"; +import { discordModels } from "./models.js"; export function discordPlugin( options: DiscordPluginOptions, @@ -13,19 +10,18 @@ export function discordPlugin( packageName: "@hackkit/plugin-discord", actionFactory: "createDiscordActions", actionNames: ["confirmDiscordVerification", "syncDiscordMemberRoles"], - models: discordModels, + entities: Object.values(discordModels), setup: (context) => createDiscordApi(context, options), }; } -export { createDiscordActions } from "./actions"; -export { createDiscordApi } from "./api"; -export { discordModels } from "./models"; -export { createDiscordHttpRoleSyncProvider } from "./providers"; +export { createDiscordApi } from "./api.js"; +export { discordModels } from "./models.js"; +export { createDiscordHttpRoleSyncProvider } from "./providers.js"; export type { ConfirmDiscordVerificationInput, DiscordActions, -} from "./actions"; +} from "./actions.js"; export type { CreateDiscordVerificationInput, DiscordApi, @@ -34,10 +30,10 @@ export type { DiscordRoleSyncInput, DiscordRoleSyncPlan, DiscordRoleSyncProvider, -} from "./api"; +} from "./api.js"; export type { DiscordMember, DiscordRoleSyncAttempt, DiscordVerification, -} from "./models"; -export type { DiscordHttpRoleSyncProviderOptions } from "./providers"; +} from "./models.js"; +export type { DiscordHttpRoleSyncProviderOptions } from "./providers.js"; diff --git a/packages/plugin-discord/src/models.ts b/packages/plugin-discord/src/models.ts index 2f31e1ad..8012177d 100644 --- a/packages/plugin-discord/src/models.ts +++ b/packages/plugin-discord/src/models.ts @@ -1,66 +1,111 @@ -import { defineModel, field } from "@hackkit/core"; +import { + defineEntity, + p, + type EntityDTO, + type InferEntity, +} from "@mikro-orm/core"; +import { HackKitProfile } from "@hackkit/core"; + +export const DiscordVerification = defineEntity({ + name: "DiscordVerification", + tableName: "discord_verification", + properties: { + code: p.string().primary(), + discordUserId: p.string(), + guildId: p.string(), + username: p.string(), + avatarHash: p.string().nullable(), + authId: () => + p + .manyToOne(HackKitProfile) + .mapToPk() + .fieldName("auth_id") + .nullable() + .deleteRule("set null"), + status: p + .enum(["pending", "accepted", "rejected", "expired"] as const) + .default("pending"), + expiresAt: p.datetime(), + createdAt: p.datetime().onCreate(() => new Date()), + acceptedAt: p.datetime().nullable(), + }, + indexes: [ + { properties: ["discordUserId"] }, + { properties: ["guildId"] }, + { properties: ["authId"] }, + { properties: ["status"] }, + { properties: ["createdAt"] }, + ], +}); + +export const DiscordMember = defineEntity({ + name: "DiscordMember", + tableName: "discord_member", + properties: { + authId: () => + p + .manyToOne(HackKitProfile) + .mapToPk() + .fieldName("auth_id") + .primary() + .deleteRule("cascade"), + discordUserId: p.string().unique(), + guildId: p.string(), + username: p.string(), + avatarHash: p.string().nullable(), + verifiedAt: p.datetime().onCreate(() => new Date()), + updatedAt: p + .datetime() + .onCreate(() => new Date()) + .onUpdate(() => new Date()), + lastRoleSyncAt: p.datetime().nullable(), + }, + indexes: [ + { properties: ["discordUserId"] }, + { properties: ["guildId"] }, + { properties: ["updatedAt"] }, + ], +}); + +export const DiscordRoleSyncAttempt = defineEntity({ + name: "DiscordRoleSyncAttempt", + tableName: "discord_role_sync_attempt", + properties: { + id: p + .string() + .primary() + .onCreate(() => crypto.randomUUID()), + authId: () => + p + .manyToOne(HackKitProfile) + .mapToPk() + .fieldName("auth_id") + .deleteRule("cascade"), + discordUserId: p.string(), + guildId: p.string(), + status: p.enum(["synced", "failed", "skipped"] as const), + roleIds: p.json().default([]), + roleNames: p.json().default([]), + error: p.text().nullable(), + createdAt: p.datetime().onCreate(() => new Date()), + }, + indexes: [ + { properties: ["authId"] }, + { properties: ["discordUserId"] }, + { properties: ["status"] }, + { properties: ["createdAt"] }, + ], +}); export const discordModels = { - verification: defineModel("discord.verification", { - fields: { - code: field.string().primaryKey(), - discordUserId: field.string(), - guildId: field.string(), - username: field.string(), - avatarHash: field.string().optional(), - authId: field - .string() - .optional() - .references("core.user", "authId", { onDelete: "setNull" }), - status: field - .enum(["pending", "accepted", "rejected", "expired"] as const) - .default("pending"), - expiresAt: field.date(), - createdAt: field.date().defaultNow(), - acceptedAt: field.date().optional(), - }, - indexes: [["discordUserId"], ["guildId"], ["authId"], ["status"], ["createdAt"]], - }), - member: defineModel("discord.member", { - fields: { - authId: field - .string() - .primaryKey() - .references("core.user", "authId", { onDelete: "cascade" }), - discordUserId: field.string().unique(), - guildId: field.string(), - username: field.string(), - avatarHash: field.string().optional(), - verifiedAt: field.date().defaultNow(), - updatedAt: field.date().defaultNow(), - lastRoleSyncAt: field.date().optional(), - }, - indexes: [["discordUserId"], ["guildId"], ["updatedAt"]], - }), - roleSyncAttempt: defineModel("discord.roleSyncAttempt", { - fields: { - id: field.string().primaryKey().defaultId(), - authId: field - .string() - .references("core.user", "authId", { onDelete: "cascade" }), - discordUserId: field.string(), - guildId: field.string(), - status: field.enum(["synced", "failed", "skipped"] as const), - roleIds: field.json().default([]), - roleNames: field.json().default([]), - error: field.string().optional(), - createdAt: field.date().defaultNow(), - }, - indexes: [["authId"], ["discordUserId"], ["status"], ["createdAt"]], - }), + verification: DiscordVerification, + member: DiscordMember, + roleSyncAttempt: DiscordRoleSyncAttempt, } as const; - -export type DiscordVerification = import("@hackkit/core").InferSelect< - typeof discordModels.verification ->; -export type DiscordMember = import("@hackkit/core").InferSelect< - typeof discordModels.member +export type DiscordVerification = EntityDTO< + InferEntity >; -export type DiscordRoleSyncAttempt = import("@hackkit/core").InferSelect< - typeof discordModels.roleSyncAttempt +export type DiscordMember = EntityDTO>; +export type DiscordRoleSyncAttempt = EntityDTO< + InferEntity >; diff --git a/packages/plugin-discord/src/providers.ts b/packages/plugin-discord/src/providers.ts index 45162ea1..99c86be2 100644 --- a/packages/plugin-discord/src/providers.ts +++ b/packages/plugin-discord/src/providers.ts @@ -1,4 +1,4 @@ -import type { DiscordRoleSyncProvider } from "./api"; +import type { DiscordRoleSyncProvider } from "./api.js"; export type DiscordHttpRoleSyncProviderOptions = { baseUrl: string; @@ -28,10 +28,17 @@ export function createDiscordHttpRoleSyncProvider( body: JSON.stringify(input), }); if (!response.ok) { - throw new Error(`Discord role sync failed with ${response.status}.`); + throw new Error( + `Discord role sync failed with ${response.status}.`, + ); } const body = await response.json().catch(() => undefined); - if (body && typeof body === "object" && "success" in body && !body.success) { + if ( + body && + typeof body === "object" && + "success" in body && + !body.success + ) { throw new Error("Discord role sync provider returned failure."); } return body && typeof body === "object" && "externalId" in body diff --git a/packages/plugin-notifications-email/package.json b/packages/plugin-notifications-email/package.json index 464f252d..2ea167c1 100644 --- a/packages/plugin-notifications-email/package.json +++ b/packages/plugin-notifications-email/package.json @@ -8,7 +8,7 @@ "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./src/index.ts", + "import": "./dist/index.js", "default": "./dist/index.js" } }, @@ -25,8 +25,8 @@ "@hackkit/core": "workspace:*" }, "devDependencies": { - "@types/node": "20.14.11", - "typescript": "5.5.3", + "@types/node": "22.18.6", + "typescript": "5.9.3", "vitest": "^3.2.4" }, "license": "MIT", diff --git a/packages/plugin-notifications-email/src/__tests__/email-notifications.test.ts b/packages/plugin-notifications-email/src/__tests__/email-notifications.test.ts index a18a45eb..fdcfbfbb 100644 --- a/packages/plugin-notifications-email/src/__tests__/email-notifications.test.ts +++ b/packages/plugin-notifications-email/src/__tests__/email-notifications.test.ts @@ -1,18 +1,17 @@ -import { describe, expect, it } from "vitest"; import { - CoreNotificationKind, - createHackkit, - createInMemoryDatabaseAdapterFromStorage, - createPluginRegistry, - type HackKitPlugin, -} from "@hackkit/core"; + createTestHackkit, + createTestUser, + type TestHackkit, +} from "@hackkit/core/testing"; +import { describe, expect, it } from "vitest"; +import { CoreNotificationKind } from "@hackkit/core"; import { emailNotificationsPlugin, type EmailMessage, type EmailNotificationsApi, -} from "../index"; +} from "../index.js"; -function createTestHackkit() { +async function createEmailHackkit() { const sent: EmailMessage[] = []; const plugin = emailNotificationsPlugin({ from: "HackKit ", @@ -26,33 +25,21 @@ function createTestHackkit() { }, }, }); - const registry = createPluginRegistry([plugin as unknown as HackKitPlugin]); - const now = () => new Date("2026-05-24T12:00:00.000Z"); - let counter = 0; - const id = () => `id-${++counter}`; - const db = createInMemoryDatabaseAdapterFromStorage( - registry.storage, - now, - id, - ); - const hackkit = createHackkit({ - database: db, - plugins: [plugin as unknown as HackKitPlugin], - clock: now, - id, + const hackkit = await createTestHackkit({ + plugins: [plugin], }); return { hackkit, - notificationsEmail: - hackkit.plugins.notificationsEmail as unknown as EmailNotificationsApi, + notificationsEmail: hackkit.plugins.notificationsEmail, sent, }; } describe("email notifications plugin", () => { it("delivers pending intents to resolved user email addresses", async () => { - const { hackkit, notificationsEmail, sent } = createTestHackkit(); - await hackkit.users.ensureUser({ + const { hackkit, notificationsEmail, sent } = + await createEmailHackkit(); + await createTestUser(hackkit, { authId: "hacker-auth", email: "hacker@example.com", firstName: "Hack", diff --git a/packages/plugin-notifications-email/src/api.ts b/packages/plugin-notifications-email/src/api.ts index aa5e7134..2e50e960 100644 --- a/packages/plugin-notifications-email/src/api.ts +++ b/packages/plugin-notifications-email/src/api.ts @@ -1,10 +1,10 @@ -import { coreModels, type HackKitPluginContext } from "@hackkit/core"; +import { type HackKitPluginContext } from "@hackkit/core"; import type { NotificationChannel, NotificationDeliveryResult, NotificationIntent, } from "@hackkit/core"; -import { mergeEmailTemplates, type EmailTemplateMap } from "./templates"; +import { mergeEmailTemplates, type EmailTemplateMap } from "./templates.js"; export type EmailMessage = { to: string; @@ -51,9 +51,11 @@ async function defaultRecipientResolver( ): Promise { const authId = intent.recipientAuthId ?? - (typeof intent.payload.authId === "string" ? intent.payload.authId : undefined); + (typeof intent.payload.authId === "string" + ? intent.payload.authId + : undefined); if (!authId) return null; - const user = await context.database.findOne(coreModels.user, { authId }); + const user = await context.getUser(authId); if (!user) return null; return { email: user.email, @@ -71,7 +73,8 @@ export function createEmailNotificationsApi( options: EmailNotificationsPluginOptions, ) { const templates = mergeEmailTemplates(options.templates); - const resolveRecipient = options.resolveRecipient ?? defaultRecipientResolver; + const resolveRecipient = + options.resolveRecipient ?? defaultRecipientResolver; const channel: NotificationChannel = { id: "email", diff --git a/packages/plugin-notifications-email/src/index.ts b/packages/plugin-notifications-email/src/index.ts index d3e153b0..f4a962de 100644 --- a/packages/plugin-notifications-email/src/index.ts +++ b/packages/plugin-notifications-email/src/index.ts @@ -2,11 +2,14 @@ import type { HackKitPlugin } from "@hackkit/core"; import { createEmailNotificationsApi, type EmailNotificationsPluginOptions, -} from "./api"; +} from "./api.js"; export function emailNotificationsPlugin( options: EmailNotificationsPluginOptions, -): HackKitPlugin<"notificationsEmail", ReturnType> { +): HackKitPlugin< + "notificationsEmail", + ReturnType +> { return { id: "notificationsEmail", packageName: "@hackkit/plugin-notifications-email", @@ -23,7 +26,7 @@ export { type EmailProviderResult, type EmailRecipient, type EmailRecipientResolver, -} from "./api"; +} from "./api.js"; export { defaultEmailTemplates, mergeEmailTemplates, @@ -31,10 +34,10 @@ export { type EmailTemplateContext, type EmailTemplateMap, type EmailTemplateRenderer, -} from "./templates"; +} from "./templates.js"; export { createResendEmailProvider, createSmtpEmailProvider, type ResendEmailProviderOptions, type SmtpEmailProviderOptions, -} from "./providers"; +} from "./providers.js"; diff --git a/packages/plugin-notifications-email/src/providers.ts b/packages/plugin-notifications-email/src/providers.ts index df3f116f..2a1b5358 100644 --- a/packages/plugin-notifications-email/src/providers.ts +++ b/packages/plugin-notifications-email/src/providers.ts @@ -1,6 +1,6 @@ import net from "node:net"; import tls from "node:tls"; -import type { EmailMessage, EmailProvider } from "./api"; +import type { EmailMessage, EmailProvider } from "./api.js"; export type ResendEmailProviderOptions = { apiKey: string; @@ -41,14 +41,19 @@ export function createResendEmailProvider( }), }, ); - const body = parseResendResponse(await response.json().catch(() => ({}))); + const body = parseResendResponse( + await response.json().catch(() => ({})), + ); if (!response.ok) { throw new Error( body.error?.message ?? `Resend email request failed with ${response.status}.`, ); } - return { id: body.id, metadata: { providerStatus: response.status } }; + return { + id: body.id, + metadata: { providerStatus: response.status }, + }; }, }; } @@ -112,7 +117,11 @@ class SmtpClient { static connect(options: SmtpEmailProviderOptions): Promise { const port = options.port ?? (options.secure ? 465 : 587); const socket = options.secure - ? tls.connect({ host: options.host, port, servername: options.host }) + ? tls.connect({ + host: options.host, + port, + servername: options.host, + }) : net.connect({ host: options.host, port }); return new Promise((resolve, reject) => { socket.once("error", reject); @@ -149,7 +158,10 @@ class SmtpClient { }); } - async command(command: string, expected: readonly number[]): Promise { + async command( + command: string, + expected: readonly number[], + ): Promise { this.socket.write(`${command}\r\n`); const response = await this.readResponse(); const code = Number(response.slice(0, 3)); @@ -166,7 +178,9 @@ class SmtpClient { }); return new Promise((resolve, reject) => { secureSocket.once("error", reject); - secureSocket.once("secureConnect", () => resolve(new SmtpClient(secureSocket))); + secureSocket.once("secureConnect", () => + resolve(new SmtpClient(secureSocket)), + ); }); } } @@ -191,8 +205,14 @@ export function createSmtpEmailProvider( await client.command(encodeBase64(options.username), [334]); await client.command(encodeBase64(options.password), [235]); } - await client.command(`MAIL FROM:<${normalizeAddress(message.from)}>`, [250]); - await client.command(`RCPT TO:<${normalizeAddress(message.to)}>`, [250, 251]); + await client.command( + `MAIL FROM:<${normalizeAddress(message.from)}>`, + [250], + ); + await client.command( + `RCPT TO:<${normalizeAddress(message.to)}>`, + [250, 251], + ); await client.command("DATA", [354]); await client.command(`${buildSmtpMessage(message)}\r\n.`, [250]); await client.command("QUIT", [221]); diff --git a/packages/plugin-teams/package.json b/packages/plugin-teams/package.json index e5856d86..8bb491ba 100644 --- a/packages/plugin-teams/package.json +++ b/packages/plugin-teams/package.json @@ -8,7 +8,7 @@ "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./src/index.ts", + "import": "./dist/index.js", "default": "./dist/index.js" }, "./app/teams/page": { @@ -19,7 +19,12 @@ "types": "./app/invites/page.tsx", "default": "./app/invites/page.tsx" }, - "./app/*": "./app/*" + "./app/*": "./app/*", + "./actions": { + "types": "./dist/actions.d.ts", + "import": "./dist/actions.js", + "default": "./dist/actions.js" + } }, "files": [ "dist", @@ -36,7 +41,8 @@ "@hackkit/next": "workspace:*", "@hackkit/ui": "workspace:*", "sonner": "^1.5.0", - "zod": "^3.25.67" + "zod": "^3.25.67", + "@mikro-orm/core": "7.2.1" }, "peerDependencies": { "next": ">=14", @@ -46,7 +52,7 @@ "devDependencies": { "@types/react": "18.3.3", "@types/react-dom": "18.3.0", - "typescript": "5.5.3", + "typescript": "5.9.3", "vitest": "^3.2.4" }, "license": "MIT", diff --git a/packages/plugin-teams/src/__tests__/teams.test.ts b/packages/plugin-teams/src/__tests__/teams.test.ts index 98425eed..93e83386 100644 --- a/packages/plugin-teams/src/__tests__/teams.test.ts +++ b/packages/plugin-teams/src/__tests__/teams.test.ts @@ -1,39 +1,27 @@ -import { describe, expect, it } from "vitest"; import { - createHackkit, - createInMemoryDatabaseAdapterFromStorage, - createPluginRegistry, - type HackKitPlugin, -} from "@hackkit/core"; -import type { TeamsApi } from "../api"; -import { teamsPlugin } from "../index"; - -function createTeamsHackkit() { + createTestHackkit, + createTestUser, + type TestHackkit, +} from "@hackkit/core/testing"; +import { describe, expect, it } from "vitest"; + +import { teamsModels } from "../models.js"; +import { teamsPlugin } from "../index.js"; + +async function createTeamsHackkit() { const plugin = teamsPlugin(); - const registry = createPluginRegistry([plugin as unknown as HackKitPlugin]); - const now = () => new Date("2026-05-24T12:00:00.000Z"); - let counter = 0; - const id = () => `id-${++counter}`; - const db = createInMemoryDatabaseAdapterFromStorage( - registry.storage, - now, - id, - ); - const hackkit = createHackkit({ - database: db, - plugins: [plugin as unknown as HackKitPlugin], - clock: now, - id, + const hackkit = await createTestHackkit({ + plugins: [plugin], }); - return { hackkit, teams: hackkit.plugins.teams as unknown as TeamsApi }; + return { hackkit, teams: hackkit.plugins.teams }; } async function seedUserWithHackTag( - hackkit: ReturnType, + hackkit: TestHackkit]>, authId: string, hackTag: string, ) { - await hackkit.users.ensureUser({ + await createTestUser(hackkit, { authId, email: `${authId}@example.com`, firstName: "Test", @@ -55,16 +43,17 @@ async function seedUserWithHackTag( } async function seedHacker( - hackkit: ReturnType, + hackkit: TestHackkit]>, authId: string, hackTag?: string, ) { - await hackkit.users.ensureUser({ - authId, - email: `${authId}@example.com`, - firstName: "Test", - lastName: "User", - }); + if (!(await hackkit.users.getUser(authId))) + await createTestUser(hackkit, { + authId, + email: `${authId}@example.com`, + firstName: "Test", + lastName: "User", + }); await hackkit.userData.completeUserData({ authId, age: 20, @@ -92,7 +81,7 @@ async function seedHacker( describe("teams plugin", () => { it("creates a team and adds the owner as a member", async () => { - const { hackkit, teams } = createTeamsHackkit(); + const { hackkit, teams } = await createTeamsHackkit(); await seedHacker(hackkit, "owner-auth", "owner"); const team = await teams.createTeam({ @@ -107,7 +96,7 @@ describe("teams plugin", () => { }); it("accepts an invite and enforces one team per hacker", async () => { - const { hackkit, teams } = createTeamsHackkit(); + const { hackkit, teams } = await createTeamsHackkit(); await seedHacker(hackkit, "owner-auth", "owner"); await seedHacker(hackkit, "member-auth", "member"); @@ -152,7 +141,7 @@ describe("teams plugin", () => { }); it("allows inviting a user before hacker registration", async () => { - const { hackkit, teams } = createTeamsHackkit(); + const { hackkit, teams } = await createTeamsHackkit(); await seedHacker(hackkit, "owner-auth", "owner"); await seedUserWithHackTag(hackkit, "pending-auth", "pending"); @@ -176,7 +165,8 @@ describe("teams plugin", () => { accept: true, }), ).rejects.toMatchObject({ - message: "Complete hacker registration before accepting a team invite.", + message: + "Complete hacker registration before accepting a team invite.", }); await seedHacker(hackkit, "pending-auth"); @@ -188,3 +178,72 @@ describe("teams plugin", () => { expect(joined).toMatchObject({ id: team.id }); }); }); + +it("creates only one team when the same owner submits concurrently", async () => { + const { hackkit } = await createTeamsHackkit(); + await seedHacker(hackkit, "owner-auth", "owner"); + const results = await Promise.allSettled( + ["first", "second"].map((tag) => + hackkit.runtime.createScope().hackkit.plugins.teams.createTeam({ + actorAuthId: "owner-auth", + name: tag, + tag, + }), + ), + ); + expect( + results.filter((result) => result.status === "fulfilled"), + ).toHaveLength(1); + const em = hackkit.runtime.createScope().em; + expect(await em.count(teamsModels.team, {})).toBe(1); + expect(await em.count(teamsModels.member, {})).toBe(1); +}); + +it("keeps the final team place and losing invite consistent across concurrent joins", async () => { + const { hackkit, teams } = await createTeamsHackkit(); + await seedHacker(hackkit, "owner-auth", "owner"); + const team = await teams.createTeam({ + actorAuthId: "owner-auth", + name: "Race", + tag: "race", + }); + const invites = []; + for (const authId of ["a", "b", "c", "d"]) { + await seedHacker(hackkit, authId, authId); + invites.push( + await teams.inviteToTeam({ + actorAuthId: "owner-auth", + teamId: team.id, + inviteeAuthId: authId, + }), + ); + } + const results = await Promise.allSettled( + invites.map((invite) => + hackkit.runtime + .createScope() + .hackkit.plugins.teams.respondToInvite({ + actorAuthId: invite.inviteeAuthId, + inviteId: invite.id, + accept: true, + }), + ), + ); + expect( + results.filter((result) => result.status === "fulfilled"), + ).toHaveLength(3); + const em = hackkit.runtime.createScope().em; + expect(await em.count(teamsModels.member, { teamId: team.id })).toBe(4); + expect( + await em.count(teamsModels.invite, { + teamId: team.id, + status: "accepted", + }), + ).toBe(3); + expect( + await em.count(teamsModels.invite, { + teamId: team.id, + status: "pending", + }), + ).toBe(1); +}); diff --git a/packages/plugin-teams/src/actions.ts b/packages/plugin-teams/src/actions.ts index 9420bb0f..c633f373 100644 --- a/packages/plugin-teams/src/actions.ts +++ b/packages/plugin-teams/src/actions.ts @@ -20,7 +20,7 @@ export type RemoveMemberInput = { memberAuthId: string; }; -import type { TeamsApi } from "./api"; +import type { TeamsApi } from "./api.js"; export function createTeamsActions(runtime: HackkitRuntime) { const teams = runtime.hackkit.plugins.teams as unknown as TeamsApi; diff --git a/packages/plugin-teams/src/api.ts b/packages/plugin-teams/src/api.ts index 5c029828..b5d271cf 100644 --- a/packages/plugin-teams/src/api.ts +++ b/packages/plugin-teams/src/api.ts @@ -1,8 +1,14 @@ -import { coreModels, HackKitError } from "@hackkit/core"; +import { wrap } from "@mikro-orm/core"; +import { coreModels, HackKitError, withOperationLock } from "@hackkit/core"; import type { AuthId, User } from "@hackkit/core"; import type { HackKitPluginContext } from "@hackkit/core"; -import { teamsModels, type Team, type TeamInvite, type TeamMember } from "./models"; -import { TeamsSetting } from "./settings"; +import { + teamsModels, + type Team, + type TeamInvite, + type TeamMember, +} from "./models.js"; +import { TeamsSetting } from "./settings.js"; export type TeamWithMembers = Team & { members: (TeamMember & { user: User })[]; @@ -16,7 +22,7 @@ async function requireHacker( authId: AuthId, message = "Only Hackers can participate in teams.", ): Promise { - const hacker = await context.database.findOne(coreModels.hacker, { authId }); + const hacker = await context.em.findOne(coreModels.hacker, { authId }); if (!hacker) { throw new HackKitError("INVALID_OPERATION", message); } @@ -26,14 +32,14 @@ async function getMemberForAuthId( context: HackKitPluginContext, authId: AuthId, ): Promise { - return context.database.findOne(teamsModels.member, { authId }); + return context.em.findOne(teamsModels.member, { authId }); } async function getTeamOrThrow( context: HackKitPluginContext, teamId: string, ): Promise { - const team = await context.database.findOne(teamsModels.team, { id: teamId }); + const team = await context.em.findOne(teamsModels.team, { id: teamId }); if (!team) { throw new HackKitError("NOT_FOUND", "Team not found."); } @@ -46,10 +52,7 @@ async function assertNotOnTeam( ): Promise { const member = await getMemberForAuthId(context, authId); if (member) { - throw new HackKitError( - "CONFLICT", - "User is already on a team.", - ); + throw new HackKitError("CONFLICT", "User is already on a team."); } } @@ -61,11 +64,14 @@ async function resolveInviteeAuthId( if (!target.hackTag?.trim()) { throw new HackKitError("INVALID_OPERATION", "Invitee is required."); } - const user = await context.database.findOne(coreModels.user, { + const user = await context.em.findOne(coreModels.user, { hackTag: target.hackTag.trim().toLowerCase(), }); if (!user) { - throw new HackKitError("NOT_FOUND", "User with that HackTag was not found."); + throw new HackKitError( + "NOT_FOUND", + "User with that HackTag was not found.", + ); } return user.authId; } @@ -74,13 +80,16 @@ async function assertTeamHasRoom( context: HackKitPluginContext, teamId: string, ): Promise { - const maximumTeamSize = await context.getSettingValue(TeamsSetting.MaximumTeamSize); + const maximumTeamSize = await context.getSettingValue( + TeamsSetting.MaximumTeamSize, + ); if (typeof maximumTeamSize !== "number" || maximumTeamSize === 0) return; - const members = await context.database.findMany(teamsModels.member, { - where: { teamId }, - }); + const members = await context.em.find(teamsModels.member, { teamId }); if (members.length >= maximumTeamSize) { - throw new HackKitError("INVALID_OPERATION", "Maximum team size has been reached."); + throw new HackKitError( + "INVALID_OPERATION", + "Maximum team size has been reached.", + ); } } @@ -88,26 +97,28 @@ async function hydrateTeam( context: HackKitPluginContext, team: Team, ): Promise { - const members = await context.database.findMany(teamsModels.member, { - where: { teamId: team.id }, - orderBy: { field: "joinedAt", direction: "asc" }, - }); + const members = await context.em.find( + teamsModels.member, + { teamId: team.id }, + { orderBy: { joinedAt: "asc" } }, + ); const users = await Promise.all( members.map(async (member) => { - const user = await context.database.findOne(coreModels.user, { - authId: member.authId, - }); + const user = await context.getUser(member.authId); if (!user) { - throw new HackKitError("NOT_FOUND", "Team member user not found."); + throw new HackKitError( + "NOT_FOUND", + "Team member user not found.", + ); } - return { ...member, user }; + return { ...wrap(member).toObject(), user }; }), ); - return { ...team, members: users }; + return { ...wrap(team).toObject(), members: users }; } export function createTeamsApi(context: HackKitPluginContext) { - const { database, registry } = context; + const { em, registry } = context; return { async createTeam(input: { @@ -115,31 +126,43 @@ export function createTeamsApi(context: HackKitPluginContext) { name: string; tag: string; }): Promise { - await requireHacker(context, input.actorAuthId); - await assertNotOnTeam(context, input.actorAuthId); + return withOperationLock(em, "teams.membership", async () => { + await requireHacker(context, input.actorAuthId); + await assertNotOnTeam(context, input.actorAuthId); - const normalizedTag = input.tag.trim().toLowerCase(); - if (!normalizedTag) { - throw new HackKitError("INVALID_OPERATION", "Team tag is required."); - } + const normalizedTag = input.tag.trim().toLowerCase(); + if (!normalizedTag) { + throw new HackKitError( + "INVALID_OPERATION", + "Team tag is required.", + ); + } - const existingTag = await database.findOne(teamsModels.team, { - tag: normalizedTag, - }); - if (existingTag) { - throw new HackKitError("CONFLICT", "Team tag is already taken."); - } + const existingTag = await em.findOne(teamsModels.team, { + tag: normalizedTag, + }); + if (existingTag) { + throw new HackKitError( + "CONFLICT", + "Team tag is already taken.", + ); + } - const team = await database.insert(teamsModels.team, { - name: input.name.trim(), - tag: normalizedTag, - ownerAuthId: input.actorAuthId, - }); - await database.insert(teamsModels.member, { - teamId: team.id, - authId: input.actorAuthId, + const team = em.create(teamsModels.team, { + name: input.name.trim(), + tag: normalizedTag, + ownerAuthId: input.actorAuthId, + }); + await em.flush(); + { + em.create(teamsModels.member, { + teamId: team.id, + authId: input.actorAuthId, + }); + await em.flush(); + } + return hydrateTeam(context, team); }); - return hydrateTeam(context, team); }, async inviteToTeam(input: { @@ -148,40 +171,49 @@ export function createTeamsApi(context: HackKitPluginContext) { inviteeAuthId?: AuthId; hackTag?: string; }): Promise { - const team = await getTeamOrThrow(context, input.teamId); - if (team.ownerAuthId !== input.actorAuthId) { - throw new HackKitError( - "FORBIDDEN", - "Only the team owner can send invites.", - ); - } + return withOperationLock(em, "teams.membership", async () => { + const team = await getTeamOrThrow(context, input.teamId); + if (team.ownerAuthId !== input.actorAuthId) { + throw new HackKitError( + "FORBIDDEN", + "Only the team owner can send invites.", + ); + } - const inviteeAuthId = await resolveInviteeAuthId(context, input); - if (inviteeAuthId === input.actorAuthId) { - throw new HackKitError( - "INVALID_OPERATION", - "You cannot invite yourself.", + const inviteeAuthId = await resolveInviteeAuthId( + context, + input, ); - } + if (inviteeAuthId === input.actorAuthId) { + throw new HackKitError( + "INVALID_OPERATION", + "You cannot invite yourself.", + ); + } - await assertNotOnTeam(context, inviteeAuthId); + await assertNotOnTeam(context, inviteeAuthId); - const existingInvite = await database.findOne(teamsModels.invite, { - teamId: input.teamId, - inviteeAuthId, - status: "pending", - }); - if (existingInvite) { - throw new HackKitError( - "CONFLICT", - "An invite is already pending for this user.", - ); - } + const existingInvite = await em.findOne(teamsModels.invite, { + teamId: input.teamId, + inviteeAuthId, + status: "pending", + }); + if (existingInvite) { + throw new HackKitError( + "CONFLICT", + "An invite is already pending for this user.", + ); + } - return database.insert(teamsModels.invite, { - teamId: input.teamId, - inviteeAuthId, - status: "pending", + { + const created = em.create(teamsModels.invite, { + teamId: input.teamId, + inviteeAuthId, + status: "pending", + }); + await em.flush(); + return wrap(created).toObject(); + } }); }, @@ -190,115 +222,156 @@ export function createTeamsApi(context: HackKitPluginContext) { inviteId: string; accept: boolean; }): Promise { - const invite = await database.findOne(teamsModels.invite, { - id: input.inviteId, - }); - if (!invite) { - throw new HackKitError("NOT_FOUND", "Invite not found."); - } - if (invite.inviteeAuthId !== input.actorAuthId) { - throw new HackKitError( - "FORBIDDEN", - "You can only respond to your own invites.", - ); - } - if (invite.status !== "pending") { - throw new HackKitError( - "INVALID_OPERATION", - "Invite has already been responded to.", - ); - } + return withOperationLock(em, "teams.membership", async () => { + const invite = await em.findOne(teamsModels.invite, { + id: input.inviteId, + }); + if (!invite) { + throw new HackKitError("NOT_FOUND", "Invite not found."); + } + if (invite.inviteeAuthId !== input.actorAuthId) { + throw new HackKitError( + "FORBIDDEN", + "You can only respond to your own invites.", + ); + } + if (invite.status !== "pending") { + throw new HackKitError( + "INVALID_OPERATION", + "Invite has already been responded to.", + ); + } - if (!input.accept) { - const [updated] = await database.update( - teamsModels.invite, - { id: invite.id }, - { status: "declined" }, - ); - return updated ?? { ...invite, status: "declined" }; - } + if (!input.accept) { + const updated = await em.findOne(teamsModels.invite, { + id: invite.id, + }); + if (updated) { + em.assign( + updated, + { status: "declined" }, + { ignoreUndefined: true }, + ); + await em.flush(); + } + return wrap(updated ?? invite).toObject(); + } - await requireHacker( - context, - input.actorAuthId, - "Complete hacker registration before accepting a team invite.", - ); - await assertNotOnTeam(context, input.actorAuthId); + await requireHacker( + context, + input.actorAuthId, + "Complete hacker registration before accepting a team invite.", + ); + await assertNotOnTeam(context, input.actorAuthId); - await assertTeamHasRoom(context, invite.teamId); - await database.insert(teamsModels.member, { - teamId: invite.teamId, - authId: input.actorAuthId, + await assertTeamHasRoom(context, invite.teamId); + { + em.create(teamsModels.member, { + teamId: invite.teamId, + authId: input.actorAuthId, + }); + await em.flush(); + } + { + const updatedRows = await em.find(teamsModels.invite, { + id: invite.id, + }); + for (const row of updatedRows) + em.assign( + row, + { status: "accepted" }, + { ignoreUndefined: true }, + ); + await em.flush(); + } + const team = await getTeamOrThrow(context, invite.teamId); + return hydrateTeam(context, team); }); - await database.update( - teamsModels.invite, - { id: invite.id }, - { status: "accepted" }, - ); - const team = await getTeamOrThrow(context, invite.teamId); - return hydrateTeam(context, team); }, async leaveTeam(input: { actorAuthId: AuthId }): Promise { - const member = await getMemberForAuthId(context, input.actorAuthId); - if (!member) { - throw new HackKitError("NOT_FOUND", "You are not on a team."); - } - const team = await getTeamOrThrow(context, member.teamId); - if (team.ownerAuthId === input.actorAuthId) { - throw new HackKitError( - "INVALID_OPERATION", - "Team owners cannot leave their team.", + return withOperationLock(em, "teams.membership", async () => { + const member = await getMemberForAuthId( + context, + input.actorAuthId, ); - } - await database.delete(teamsModels.member, { id: member.id }); + if (!member) { + throw new HackKitError( + "NOT_FOUND", + "You are not on a team.", + ); + } + const team = await getTeamOrThrow(context, member.teamId); + if (team.ownerAuthId === input.actorAuthId) { + throw new HackKitError( + "INVALID_OPERATION", + "Team owners cannot leave their team.", + ); + } + await em.nativeDelete(teamsModels.member, { id: member.id }); + }); }, async removeMember(input: { actorAuthId: AuthId; memberAuthId: AuthId; }): Promise { - const member = await getMemberForAuthId(context, input.memberAuthId); - if (!member) { - throw new HackKitError("NOT_FOUND", "Team member not found."); - } - const team = await getTeamOrThrow(context, member.teamId); - if (team.ownerAuthId !== input.actorAuthId) { - throw new HackKitError( - "FORBIDDEN", - "Only the team owner can remove members.", - ); - } - if (member.authId === team.ownerAuthId) { - throw new HackKitError( - "INVALID_OPERATION", - "The team owner cannot be removed.", + return withOperationLock(em, "teams.membership", async () => { + const member = await getMemberForAuthId( + context, + input.memberAuthId, ); - } - await database.delete(teamsModels.member, { id: member.id }); + if (!member) { + throw new HackKitError( + "NOT_FOUND", + "Team member not found.", + ); + } + const team = await getTeamOrThrow(context, member.teamId); + if (team.ownerAuthId !== input.actorAuthId) { + throw new HackKitError( + "FORBIDDEN", + "Only the team owner can remove members.", + ); + } + if (member.authId === team.ownerAuthId) { + throw new HackKitError( + "INVALID_OPERATION", + "The team owner cannot be removed.", + ); + } + await em.nativeDelete(teamsModels.member, { id: member.id }); + }); }, - async getTeamForAuthId(authId: AuthId): Promise { + async getTeamForAuthId( + authId: AuthId, + ): Promise { const member = await getMemberForAuthId(context, authId); if (!member) return null; const team = await getTeamOrThrow(context, member.teamId); return hydrateTeam(context, team); }, - async listTeamMembers(teamId: string): Promise<(TeamMember & { user: User })[]> { + async listTeamMembers( + teamId: string, + ): Promise<(TeamMember & { user: User })[]> { const team = await getTeamOrThrow(context, teamId); return (await hydrateTeam(context, team)).members; }, async listPendingInvites(authId: AuthId): Promise { - const invites = await database.findMany(teamsModels.invite, { - where: { inviteeAuthId: authId, status: "pending" }, - orderBy: { field: "createdAt", direction: "desc" }, - }); + const invites = await em.find( + teamsModels.invite, + { inviteeAuthId: authId, status: "pending" }, + { orderBy: { createdAt: "desc" } }, + ); return Promise.all( invites.map(async (invite) => ({ - ...invite, - team: await getTeamOrThrow(context, invite.teamId), + ...wrap(invite).toObject(), + team: wrap( + await getTeamOrThrow(context, invite.teamId), + ).toObject(), })), ); }, @@ -314,22 +387,21 @@ export function createTeamsApi(context: HackKitPluginContext) { "Only the team owner can view team invites.", ); } - const invites = await database.findMany(teamsModels.invite, { - where: { teamId: input.teamId }, - orderBy: { field: "createdAt", direction: "desc" }, - }); + const invites = await em.find( + teamsModels.invite, + { teamId: input.teamId }, + { orderBy: { createdAt: "desc" } }, + ); return Promise.all( invites.map(async (invite) => { - const invitee = await database.findOne(coreModels.user, { - authId: invite.inviteeAuthId, - }); + const invitee = await context.getUser(invite.inviteeAuthId); if (!invitee) { throw new HackKitError( "NOT_FOUND", "Invited user not found.", ); } - return { ...invite, invitee }; + return { ...wrap(invite).toObject(), invitee }; }), ); }, diff --git a/packages/plugin-teams/src/components/team-dashboard.tsx b/packages/plugin-teams/src/components/team-dashboard.tsx index 67358d36..3e37586b 100644 --- a/packages/plugin-teams/src/components/team-dashboard.tsx +++ b/packages/plugin-teams/src/components/team-dashboard.tsx @@ -6,20 +6,14 @@ import { toast } from "sonner"; import type { User } from "@hackkit/core"; import type { HackKitActionResult } from "@hackkit/ui"; import { Button } from "@hackkit/ui"; -import type { - InviteToTeamInput, - RemoveMemberInput, -} from "../actions"; +import type { InviteToTeamInput, RemoveMemberInput } from "../actions"; import type { TeamInviteWithInvitee, TeamWithMembers } from "../api"; -const INVITE_STATUS_LABEL: Record< - TeamInviteWithInvitee["status"], - string -> = { - pending: "Pending", - accepted: "Accepted", - declined: "Declined", -}; +const INVITE_STATUS_LABEL = new Map([ + ["pending", "Pending"], + ["accepted", "Accepted"], + ["declined", "Declined"], +]); function formatInviteSentAt(createdAt: Date | string): string { const date = createdAt instanceof Date ? createdAt : new Date(createdAt); @@ -98,7 +92,9 @@ export function TeamDashboard({

Your team

-

{team.name}

+

+ {team.name} +

@{team.tag}

@@ -112,13 +108,16 @@ export function TeamDashboard({ >

- {member.user.firstName} {member.user.lastName} + {member.user.firstName}{" "} + {member.user.lastName}

{member.user.hackTag ? `@${member.user.hackTag}` : member.user.email} - {member.authId === team.ownerAuthId ? " · Owner" : ""} + {member.authId === team.ownerAuthId + ? " · Owner" + : ""}

{isOwner && member.authId !== team.ownerAuthId ? ( @@ -165,19 +164,25 @@ export function TeamDashboard({ : invite.invitee.email}

- Sent {formatInviteSentAt(invite.createdAt)} + Sent{" "} + {formatInviteSentAt( + invite.createdAt, + )}

- {INVITE_STATUS_LABEL[invite.status]} + {INVITE_STATUS_LABEL.get( + invite.status, + )} ))} @@ -186,24 +191,30 @@ export function TeamDashboard({
-

Invite a Hacker

- - -
+ onSubmit={handleInvite} + className="space-y-4 rounded-lg border bg-card p-6 shadow-sm" + > +

+ Invite a Hacker +

+ + + ) : ( @@ -141,7 +160,10 @@ export function AdminUserActions({ onClick={() => run( "unban", - () => actions.unbanUser(record.user.authId), + () => + actions.unbanUser( + record.user.authId, + ), "User reinstated.", ) } @@ -154,7 +176,9 @@ export function AdminUserActions({