diff --git a/bun.lock b/bun.lock index 06189305..dec6542e 100644 --- a/bun.lock +++ b/bun.lock @@ -89,6 +89,7 @@ "name": "@nestri/core", "version": "0.0.0", "dependencies": { + "@polar-sh/sdk": "^0.40.1", "drizzle-orm": "^0.44.6", "hono": "catalog:", "ioredis": "^5.8.2", @@ -109,6 +110,7 @@ }, "packages/function": { "name": "@nestri/function", + "version": "0.0.1", "dependencies": { "@openauthjs/openauth": "catalog:", "hono": "catalog:", @@ -3760,6 +3762,8 @@ "@nestri/cloud-functions/@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="], + "@nestri/core/@polar-sh/sdk": ["@polar-sh/sdk@0.40.1", "", { "dependencies": { "standardwebhooks": "^1.0.0", "zod": "^3.25.76" } }, "sha512-P/hugjmo8G6vl6hdvXcZHjC2sOaj5ZRyo2BIdn5MKgiq6vXBcWRWfCEpIjcF2cZZ8H9ZVB+0kDYb9Dp5ZkhXuA=="], + "@nestri/core/drizzle-kit": ["drizzle-kit@0.31.5", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-+CHgPFzuoTQTt7cOYCV6MOw2w8vqEn/ap1yv4bpZOWL03u7rlVRQhUY0WYT3rHsgVTXwYQDZaSUJSQrMBUKuWg=="], "@nestri/core/drizzle-orm": ["drizzle-orm@0.44.6", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-uy6uarrrEOc9K1u5/uhBFJbdF5VJ5xQ/Yzbecw3eAYOunv5FDeYkR2m8iitocdHBOHbvorviKOW5GVw0U1j4LQ=="], @@ -4840,6 +4844,8 @@ "@nestri/cloud-functions/@types/bun/bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="], + "@nestri/core/@polar-sh/sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@nestri/core/drizzle-kit/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="], "@nestri/studio/@types/bun/bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="], diff --git a/packages/core/package.json b/packages/core/package.json index 6ec41e62..192c6ef9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -19,6 +19,7 @@ "typescript": "^5" }, "dependencies": { + "@polar-sh/sdk": "^0.40.1", "drizzle-orm": "^0.44.6", "hono": "catalog:", "ioredis": "^5.8.2", diff --git a/packages/core/src/organisation/org.sql.ts b/packages/core/src/organisation/org.sql.ts index 04e4c3c8..d94b73d8 100644 --- a/packages/core/src/organisation/org.sql.ts +++ b/packages/core/src/organisation/org.sql.ts @@ -1,15 +1,19 @@ import { userTable } from "../user/user.sql.js"; -import { pgTable, varchar } from "drizzle-orm/pg-core"; +import { pgTable, unique, varchar } from "drizzle-orm/pg-core"; import { id, timestamps, ulid } from "../drizzle/types.js"; -export const orgTable = pgTable("organisations", { - ...id, - ...timestamps, - ownerID: ulid("owner_id") - .references(() => userTable.id, { - onDelete: "cascade", - }) - .notNull(), - name: varchar("name", { length: 255 }).unique().notNull(), - slug: varchar("slug", { length: 255 }).unique().notNull(), -}); +export const orgTable = pgTable( + "organisations", + { + ...id, + ...timestamps, + ownerID: ulid("owner_id") + .references(() => userTable.id, { + onDelete: "cascade", + }) + .notNull(), + name: varchar("name", { length: 255 }).notNull(), + slug: varchar("slug", { length: 255 }).notNull(), + }, + (org) => [unique("idx_organisation_slug").on(org.slug)], +); diff --git a/packages/core/src/user/index.ts b/packages/core/src/user/index.ts index fefb8db4..ceaa01d5 100644 --- a/packages/core/src/user/index.ts +++ b/packages/core/src/user/index.ts @@ -1,5 +1,11 @@ import { z } from "zod/v4"; +import { fn } from "../utils/fn.js"; +import { userTable } from "./user.sql.js"; +import { createID } from "../utils/id.js"; import { Examples } from "../examples.js"; +import { Polar } from "../polar/index.js"; +import { Database } from "../drizzle/index.js"; +import { eq, and, isNotNull } from "drizzle-orm"; export namespace User { export const Info = z @@ -8,7 +14,7 @@ export namespace User { description: "The unique identifier of the user", example: Examples.User.id, }), - name: z.string().optional().meta({ + name: z.string().nullable().meta({ description: "The full name of the user", example: Examples.User.name, }), @@ -28,4 +34,67 @@ export namespace User { }); export type Info = z.infer; + + export const create = fn( + Info.omit({ customerID: true }).partial({ id: true }), + async (input) => { + const id = input.id ?? createID("user"); + + const customerID = await Polar.fromUserEmail(input.email).then( + (z) => z.id, + ); + + await Database.transaction((tx) => + tx + .insert(userTable) + .values({ + id, + customerID, + name: input.name, + email: input.email, + }) + .onConflictDoUpdate({ + target: userTable.email, + set: { + timeDeleted: null, + }, + }), + ); + + return id; + }, + ); + + export const fromEmail = fn(Info.shape.email, (email) => + Database.transaction((tx) => + tx + .select() + .from(userTable) + .where( + and(eq(userTable.email, email), isNotNull(userTable.timeDeleted)), + ) + .then((rows) => rows.map(serialize).at(0)), + ), + ); + + export const fromID = fn(Info.shape.id, (id) => + Database.transaction((tx) => + tx + .select() + .from(userTable) + .where(and(eq(userTable.id, id), isNotNull(userTable.timeDeleted))) + .then((rows) => rows.map(serialize).at(0)), + ), + ); + + export function serialize( + input: typeof userTable.$inferSelect, + ): z.infer { + return { + id: input.id, + name: input.name, + email: input.email, + customerID: input.customerID, + }; + } } diff --git a/packages/core/src/user/user.sql.ts b/packages/core/src/user/user.sql.ts index 5b29d363..a8d92f5c 100644 --- a/packages/core/src/user/user.sql.ts +++ b/packages/core/src/user/user.sql.ts @@ -1,10 +1,14 @@ import { id, timestamps } from "../drizzle/types.js"; -import { pgTable, varchar } from "drizzle-orm/pg-core"; +import { pgTable, varchar, unique } from "drizzle-orm/pg-core"; -export const userTable = pgTable("users", { - ...id, - ...timestamps, - name: varchar("name", { length: 255 }), - email: varchar("email", { length: 255 }).unique().notNull(), - customerID: varchar("customer_id", { length: 255 }).notNull(), -}); +export const userTable = pgTable( + "users", + { + ...id, + ...timestamps, + name: varchar("name", { length: 255 }), + email: varchar("email", { length: 255 }).notNull(), + customerID: varchar("customer_id", { length: 255 }).notNull(), + }, + (user) => [unique("idx_user_email").on(user.email)], +); diff --git a/packages/core/src/utils/id.ts b/packages/core/src/utils/id.ts index 2864ec4d..4e8a1ab9 100644 --- a/packages/core/src/utils/id.ts +++ b/packages/core/src/utils/id.ts @@ -3,7 +3,6 @@ import { ulid } from "ulid"; export const prefixes = { user: "usr", steam: "stm", - team: "tm", app: "app", member: "mbr", apiSecret: "sec", @@ -14,7 +13,7 @@ export const prefixes = { /** * Generates a unique identifier by concatenating a predefined prefix with a ULID. * - * Given a key from the predefined prefixes mapping (e.g.,"team", "member", "steam"), + * Given a key from the predefined prefixes mapping (e.g.,"user", "member", "steam"), * this function retrieves the corresponding prefix and combines it with a ULID using an underscore * as a separator. The resulting identifier is formatted as "prefix_ulid". * diff --git a/packages/function/src/auth/index.ts b/packages/function/src/auth/index.ts index 9825bd9c..d620dc6b 100644 --- a/packages/function/src/auth/index.ts +++ b/packages/function/src/auth/index.ts @@ -3,7 +3,7 @@ import { RedisClient } from "bun"; import { logger } from "hono/logger"; import { subjects } from "@/subjects.js"; import { issuer } from "@openauthjs/openauth"; -import { Team } from "@nestri/core/team/index"; +import { User } from "@nestri/core/user/index"; import { RedisStorage } from "./src/storage.js"; import { CodeProvider } from "@openauthjs/openauth/provider/code"; @@ -48,15 +48,17 @@ const app = issuer({ let email = res.claims.email; if (!email) throw new Error("No email found"); - let teamID = await Team.fromEmail(email).then((x) => x?.id); - if (!teamID) { + + let userID = await User.fromEmail(email).then((x) => x?.id); + if (!userID) { console.log("creating account for", email); - teamID = await Team.create({ + userID = await User.create({ email: email!, + name: null, }); } - return ctx.subject("team", email!, { - teamID: teamID!, + return ctx.subject("user", email!, { + userID: userID!, email: email, }); }, diff --git a/packages/function/src/subjects.ts b/packages/function/src/subjects.ts index 49f788cc..de1c8a3c 100644 --- a/packages/function/src/subjects.ts +++ b/packages/function/src/subjects.ts @@ -2,8 +2,8 @@ import { z } from "zod/v4"; import { createSubjects } from "@openauthjs/openauth/subject"; export const subjects = createSubjects({ - team: z.object({ + user: z.object({ email: z.email(), - teamID: z.string(), + userID: z.string(), }), });