diff --git a/infra/api.ts b/infra/api.ts index 3a6c2c54..cdb19cd1 100644 --- a/infra/api.ts +++ b/infra/api.ts @@ -6,15 +6,21 @@ import { cluster } from "./cluster"; import { postgres } from "./postgres"; export const api = new sst.aws.Service("Api", { + cluster, cpu: $app.stage === "production" ? "2 vCPU" : undefined, memory: $app.stage === "production" ? "4 GB" : undefined, - cluster, command: ["bun", "run", "./src/api/index.ts"], link: [ bus, auth, postgres, secret.PolarSecret, + secret.PolarWebhookSecret, + secret.NestriFamilyMonthly, + secret.NestriFamilyYearly, + secret.NestriFreeMonthly, + secret.NestriProMonthly, + secret.NestriProYearly, ], image: { dockerfile: "packages/functions/Containerfile", @@ -23,16 +29,11 @@ export const api = new sst.aws.Service("Api", { NO_COLOR: "1", }, loadBalancer: { - domain: "api." + domain, rules: [ { listen: "80/http", forward: "3001/http", }, - { - listen: "443/https", - forward: "3001/http", - }, ], }, dev: { @@ -47,4 +48,16 @@ export const api = new sst.aws.Service("Api", { max: 10, } : undefined, -}); \ No newline at end of file +}); + + +export const apiRoute = new sst.aws.Router("ApiRoute", { + routes: { + // I think api.url should work all the same + "/*": api.nodes.loadBalancer.dnsName, + }, + domain: { + name: "api." + domain, + dns: sst.cloudflare.dns(), + }, +}) \ No newline at end of file diff --git a/infra/auth.ts b/infra/auth.ts index a99bab7a..4054d980 100644 --- a/infra/auth.ts +++ b/infra/auth.ts @@ -1,26 +1,14 @@ import { bus } from "./bus"; import { domain } from "./dns"; -// import { email } from "./email"; import { secret } from "./secret"; -import { postgres } from "./postgres"; import { cluster } from "./cluster"; -// sst.Linkable.wrap(random.RandomString, (resource) => ({ -// properties: { -// value: resource.result, -// }, -// })); - -// export const authFingerprintKey = new random.RandomString( -// "AuthFingerprintKey", -// { -// length: 32, -// }, -// ); +import { postgres } from "./postgres"; +//FIXME: Use a shared /tmp folder export const auth = new sst.aws.Service("Auth", { + cluster, cpu: $app.stage === "production" ? "1 vCPU" : undefined, memory: $app.stage === "production" ? "2 GB" : undefined, - cluster, command: ["bun", "run", "./src/auth.ts"], link: [ bus, @@ -38,18 +26,12 @@ export const auth = new sst.aws.Service("Auth", { NO_COLOR: "1", STORAGE: $dev ? "/tmp/persist.json" : "/mnt/efs/persist.json" }, - //TODO: Use API gateway instead, because of the API headers loadBalancer: { - domain: "auth." + domain, rules: [ { listen: "80/http", forward: "3002/http", }, - { - listen: "443/https", - forward: "3002/http", - }, ], }, permissions: [ @@ -70,4 +52,15 @@ export const auth = new sst.aws.Service("Auth", { max: 10, } : undefined, -}); \ No newline at end of file +}); + +export const authRoute = new sst.aws.Router("AuthRoute", { + routes: { + // I think auth.url should work all the same + "/*": auth.nodes.loadBalancer.dnsName, + }, + domain: { + name: "auth." + domain, + dns: sst.cloudflare.dns(), + }, +}) \ No newline at end of file diff --git a/infra/secret.ts b/infra/secret.ts index 58466b89..39d24673 100644 --- a/infra/secret.ts +++ b/infra/secret.ts @@ -1,11 +1,17 @@ export const secret = { - // InstantAppId: new sst.Secret("InstantAppId"), PolarSecret: new sst.Secret("PolarSecret", process.env.POLAR_API_KEY), GithubClientID: new sst.Secret("GithubClientID"), DiscordClientID: new sst.Secret("DiscordClientID"), + PolarWebhookSecret: new sst.Secret("PolarWebhookSecret"), GithubClientSecret: new sst.Secret("GithubClientSecret"), - // InstantAdminToken: new sst.Secret("InstantAdminToken"), DiscordClientSecret: new sst.Secret("DiscordClientSecret"), + + // Pricing + NestriFreeMonthly: new sst.Secret("NestriFreeMonthly"), + NestriProMonthly: new sst.Secret("NestriProMonthly"), + NestriProYearly: new sst.Secret("NestriProYearly"), + NestriFamilyMonthly: new sst.Secret("NestriFamilyMonthly"), + NestriFamilyYearly: new sst.Secret("NestriFamilyYearly"), }; export const allSecrets = Object.values(secret); \ No newline at end of file diff --git a/packages/core/migrations/0006_worthless_dreadnoughts.sql b/packages/core/migrations/0006_worthless_dreadnoughts.sql new file mode 100644 index 00000000..cb39fb49 --- /dev/null +++ b/packages/core/migrations/0006_worthless_dreadnoughts.sql @@ -0,0 +1,2 @@ +ALTER TABLE "member" ADD COLUMN "role" text NOT NULL;--> statement-breakpoint +ALTER TABLE "team" DROP COLUMN "plan_type"; \ No newline at end of file diff --git a/packages/core/migrations/0007_warm_secret_warriors.sql b/packages/core/migrations/0007_warm_secret_warriors.sql new file mode 100644 index 00000000..8612cced --- /dev/null +++ b/packages/core/migrations/0007_warm_secret_warriors.sql @@ -0,0 +1,15 @@ +CREATE TABLE "subscription" ( + "id" char(30) NOT NULL, + "user_id" char(30) NOT NULL, + "time_created" timestamp with time zone DEFAULT now() NOT NULL, + "time_updated" timestamp with time zone DEFAULT now() NOT NULL, + "time_deleted" timestamp with time zone, + "team_id" char(30) NOT NULL, + "standing" text NOT NULL, + "plan_type" text NOT NULL, + "tokens" integer NOT NULL, + "product_id" varchar(255), + "subscription_id" varchar(255) +); +--> statement-breakpoint +ALTER TABLE "subscription" ADD CONSTRAINT "subscription_team_id_team_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."team"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/packages/core/migrations/0008_third_mindworm.sql b/packages/core/migrations/0008_third_mindworm.sql new file mode 100644 index 00000000..3cb5d63f --- /dev/null +++ b/packages/core/migrations/0008_third_mindworm.sql @@ -0,0 +1,3 @@ +ALTER TABLE "subscription" ADD CONSTRAINT "subscription_id_team_id_pk" PRIMARY KEY("id","team_id");--> statement-breakpoint +CREATE UNIQUE INDEX "subscription_id" ON "subscription" USING btree ("id");--> statement-breakpoint +CREATE INDEX "subscription_user_id" ON "subscription" USING btree ("user_id"); \ No newline at end of file diff --git a/packages/core/migrations/0009_luxuriant_wraith.sql b/packages/core/migrations/0009_luxuriant_wraith.sql new file mode 100644 index 00000000..237df9eb --- /dev/null +++ b/packages/core/migrations/0009_luxuriant_wraith.sql @@ -0,0 +1,2 @@ +CREATE UNIQUE INDEX "steam_id" ON "steam" USING btree ("steam_id");--> statement-breakpoint +CREATE INDEX "steam_user_id" ON "steam" USING btree ("user_id"); \ No newline at end of file diff --git a/packages/core/migrations/meta/0006_snapshot.json b/packages/core/migrations/meta/0006_snapshot.json new file mode 100644 index 00000000..4701535c --- /dev/null +++ b/packages/core/migrations/meta/0006_snapshot.json @@ -0,0 +1,485 @@ +{ + "id": "69827225-1351-4709-a9b2-facb0f569215", + "prevId": "0b04858c-a7e3-43b6-98a4-1dc2f6f97488", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.machine": { + "name": "machine", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "point", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "country_code": { + "name": "country_code", + "type": "varchar(2)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "machine_fingerprint": { + "name": "machine_fingerprint", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time_seen": { + "name": "time_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "email_global": { + "name": "email_global", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_email": { + "name": "member_email", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "member_team_id_id_pk": { + "name": "member_team_id_id_pk", + "columns": [ + "team_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.steam": { + "name": "steam", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "steam_id": { + "name": "steam_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_game": { + "name": "last_game", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "country_code": { + "name": "country_code", + "type": "varchar(2)", + "primaryKey": false, + "notNull": true + }, + "steam_email": { + "name": "steam_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "persona_name": { + "name": "persona_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "limitation": { + "name": "limitation", + "type": "json", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "steam_user_id_user_id_fk": { + "name": "steam_user_id_user_id_fk", + "tableFrom": "steam", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team": { + "name": "team", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "slug": { + "name": "slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "discriminator": { + "name": "discriminator", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "polar_customer_id": { + "name": "polar_customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email": { + "name": "user_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_polar_customer_id_unique": { + "name": "user_polar_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "polar_customer_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/core/migrations/meta/0007_snapshot.json b/packages/core/migrations/meta/0007_snapshot.json new file mode 100644 index 00000000..2b428c99 --- /dev/null +++ b/packages/core/migrations/meta/0007_snapshot.json @@ -0,0 +1,580 @@ +{ + "id": "fff2b73d-85ab-48bc-86de-69d3caf317f0", + "prevId": "69827225-1351-4709-a9b2-facb0f569215", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.machine": { + "name": "machine", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "point", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "country_code": { + "name": "country_code", + "type": "varchar(2)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "machine_fingerprint": { + "name": "machine_fingerprint", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time_seen": { + "name": "time_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "email_global": { + "name": "email_global", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_email": { + "name": "member_email", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "member_team_id_id_pk": { + "name": "member_team_id_id_pk", + "columns": [ + "team_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.steam": { + "name": "steam", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "steam_id": { + "name": "steam_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_game": { + "name": "last_game", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "country_code": { + "name": "country_code", + "type": "varchar(2)", + "primaryKey": false, + "notNull": true + }, + "steam_email": { + "name": "steam_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "persona_name": { + "name": "persona_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "limitation": { + "name": "limitation", + "type": "json", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "steam_user_id_user_id_fk": { + "name": "steam_user_id_user_id_fk", + "tableFrom": "steam", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "standing": { + "name": "standing", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plan_type": { + "name": "plan_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "subscription_team_id_team_id_fk": { + "name": "subscription_team_id_team_id_fk", + "tableFrom": "subscription", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team": { + "name": "team", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "slug": { + "name": "slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "discriminator": { + "name": "discriminator", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "polar_customer_id": { + "name": "polar_customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email": { + "name": "user_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_polar_customer_id_unique": { + "name": "user_polar_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "polar_customer_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/core/migrations/meta/0008_snapshot.json b/packages/core/migrations/meta/0008_snapshot.json new file mode 100644 index 00000000..3d99e948 --- /dev/null +++ b/packages/core/migrations/meta/0008_snapshot.json @@ -0,0 +1,619 @@ +{ + "id": "17b9c14f-ff15-44a5-9aaf-3f3b7dd7d294", + "prevId": "fff2b73d-85ab-48bc-86de-69d3caf317f0", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.machine": { + "name": "machine", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "point", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "country_code": { + "name": "country_code", + "type": "varchar(2)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "machine_fingerprint": { + "name": "machine_fingerprint", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time_seen": { + "name": "time_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "email_global": { + "name": "email_global", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_email": { + "name": "member_email", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "member_team_id_id_pk": { + "name": "member_team_id_id_pk", + "columns": [ + "team_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.steam": { + "name": "steam", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "steam_id": { + "name": "steam_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_game": { + "name": "last_game", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "country_code": { + "name": "country_code", + "type": "varchar(2)", + "primaryKey": false, + "notNull": true + }, + "steam_email": { + "name": "steam_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "persona_name": { + "name": "persona_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "limitation": { + "name": "limitation", + "type": "json", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "steam_user_id_user_id_fk": { + "name": "steam_user_id_user_id_fk", + "tableFrom": "steam", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "standing": { + "name": "standing", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plan_type": { + "name": "plan_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_id": { + "name": "subscription_id", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_user_id": { + "name": "subscription_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscription_team_id_team_id_fk": { + "name": "subscription_team_id_team_id_fk", + "tableFrom": "subscription", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "subscription_id_team_id_pk": { + "name": "subscription_id_team_id_pk", + "columns": [ + "id", + "team_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team": { + "name": "team", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "slug": { + "name": "slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "discriminator": { + "name": "discriminator", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "polar_customer_id": { + "name": "polar_customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email": { + "name": "user_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_polar_customer_id_unique": { + "name": "user_polar_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "polar_customer_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/core/migrations/meta/0009_snapshot.json b/packages/core/migrations/meta/0009_snapshot.json new file mode 100644 index 00000000..ceabafea --- /dev/null +++ b/packages/core/migrations/meta/0009_snapshot.json @@ -0,0 +1,650 @@ +{ + "id": "1717c769-cee0-4242-bcbb-9538c80d985c", + "prevId": "17b9c14f-ff15-44a5-9aaf-3f3b7dd7d294", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.machine": { + "name": "machine", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "point", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "country_code": { + "name": "country_code", + "type": "varchar(2)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "machine_fingerprint": { + "name": "machine_fingerprint", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time_seen": { + "name": "time_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "email_global": { + "name": "email_global", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_email": { + "name": "member_email", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "member_team_id_id_pk": { + "name": "member_team_id_id_pk", + "columns": [ + "team_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.steam": { + "name": "steam", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "steam_id": { + "name": "steam_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_game": { + "name": "last_game", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "country_code": { + "name": "country_code", + "type": "varchar(2)", + "primaryKey": false, + "notNull": true + }, + "steam_email": { + "name": "steam_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "persona_name": { + "name": "persona_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "limitation": { + "name": "limitation", + "type": "json", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "steam_id": { + "name": "steam_id", + "columns": [ + { + "expression": "steam_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "steam_user_id": { + "name": "steam_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "steam_user_id_user_id_fk": { + "name": "steam_user_id_user_id_fk", + "tableFrom": "steam", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "standing": { + "name": "standing", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plan_type": { + "name": "plan_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_id": { + "name": "subscription_id", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_user_id": { + "name": "subscription_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscription_team_id_team_id_fk": { + "name": "subscription_team_id_team_id_fk", + "tableFrom": "subscription", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "subscription_id_team_id_pk": { + "name": "subscription_id_team_id_pk", + "columns": [ + "id", + "team_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team": { + "name": "team", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "slug": { + "name": "slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "discriminator": { + "name": "discriminator", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "polar_customer_id": { + "name": "polar_customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email": { + "name": "user_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_polar_customer_id_unique": { + "name": "user_polar_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "polar_customer_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/core/migrations/meta/_journal.json b/packages/core/migrations/meta/_journal.json index 37b26204..5adf041f 100644 --- a/packages/core/migrations/meta/_journal.json +++ b/packages/core/migrations/meta/_journal.json @@ -43,6 +43,34 @@ "when": 1744614896792, "tag": "0005_aspiring_stature", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1744634229644, + "tag": "0006_worthless_dreadnoughts", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1744634322996, + "tag": "0007_warm_secret_warriors", + "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1744651530530, + "tag": "0008_third_mindworm", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1744651817581, + "tag": "0009_luxuriant_wraith", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/core/src/actor.ts b/packages/core/src/actor.ts index 4dabf3b3..32b1e8e5 100644 --- a/packages/core/src/actor.ts +++ b/packages/core/src/actor.ts @@ -109,43 +109,34 @@ export function assertActor(type: T) { return actor as Extract; } +/** + * Returns the current actor's team ID. + * + * @returns The team ID associated with the current actor. + * @throws {VisibleError} If the current actor does not have a {@link teamID} property. + */ export function useTeam() { const actor = useActor(); if ("teamID" in actor.properties) return actor.properties.teamID; - throw new Error(`Expected actor to have teamID`); -} - -export function useMachine() { - const actor = useActor(); - if ("machineID" in actor.properties) return actor.properties.fingerprint; - throw new Error(`Expected actor to have fingerprint`); + throw new VisibleError( + "authentication", + ErrorCodes.Authentication.UNAUTHORIZED, + `Expected actor to have teamID` + ); } /** - * Asserts that the current user possesses the specified flag. + * Returns the fingerprint of the current actor if the actor has a machine identity. * - * This function executes a database transaction that queries the user table for the current user's flags. - * If the flags are missing, it throws a {@link VisibleError} with the code {@link ErrorCodes.Validation.MISSING_REQUIRED_FIELD} - * and a message indicating that the required flag is absent. - * - * @param flag - The name of the user flag to verify. - * - * @throws {VisibleError} If the user's flag is missing. + * @returns The fingerprint of the current machine actor. + * @throws {VisibleError} If the current actor does not have a machine identity. */ -export async function assertUserFlag(flag: keyof UserFlags) { - return useTransaction((tx) => - tx - .select({ flags: userTable.flags }) - .from(userTable) - .where(eq(userTable.id, useUserID())) - .then((rows) => { - const flags = rows[0]?.flags; - if (!flags) - throw new VisibleError( - "not_found", - ErrorCodes.Validation.MISSING_REQUIRED_FIELD, - "Actor does not have " + flag + " flag", - ); - }), +export function useMachine() { + const actor = useActor(); + if ("machineID" in actor.properties) return actor.properties.fingerprint; + throw new VisibleError( + "authentication", + ErrorCodes.Authentication.UNAUTHORIZED, + `Expected actor to have fingerprint` ); } \ No newline at end of file diff --git a/packages/core/src/common.ts b/packages/core/src/common.ts index 0f450f6d..9ce2b53b 100644 --- a/packages/core/src/common.ts +++ b/packages/core/src/common.ts @@ -1,7 +1,11 @@ +import { sql } from "drizzle-orm"; import { z } from "zod"; import "zod-openapi/extend"; export namespace Common { export const IdDescription = `Unique object identifier. The format and length of IDs may change over time.`; + + export const now = () => sql`now()`; + export const utc = () => sql`now() at time zone 'utc'`; } \ No newline at end of file diff --git a/packages/core/src/drizzle/types.ts b/packages/core/src/drizzle/types.ts index 00f551d6..15d266d9 100644 --- a/packages/core/src/drizzle/types.ts +++ b/packages/core/src/drizzle/types.ts @@ -1,4 +1,5 @@ import { char, timestamp as rawTs } from "drizzle-orm/pg-core"; +import { teamTable } from "../team/team.sql"; export const ulid = (name: string) => char(name, { length: 26 + 4 }); diff --git a/packages/core/src/examples.ts b/packages/core/src/examples.ts index b41ac04e..47739c30 100644 --- a/packages/core/src/examples.ts +++ b/packages/core/src/examples.ts @@ -34,23 +34,38 @@ export namespace Examples { steamAccounts: [Steam] }; - export const Team = { - id: Id("team"), - name: "John Does' Team", - slug: "john_doe", - planType: "BYOG" as const + export const Product = { + id: Id("product"), + name: "RTX 4090", + description: "Ideal for dedicated gamers who crave more flexibility and social gaming experiences.", + tokensPerHour: 20, + } + + export const Subscription = { + tokens: 100, + id: Id("subscription"), + userID: Id("user"), + teamID: Id("team"), + planType: "pro" as const, // free, pro, family, enterprise + standing: "new" as const, // new, good, overdue, cancelled + polarProductID: "0bfcb712-df13-4454-81a8-fbee66eddca4", + polarSubscriptionID: "0bfcb712-df13-4454-81a8-fbee66eddca4", } export const Member = { id: Id("member"), email: "john@example.com", teamID: Id("team"), + role: "admin" as const, timeSeen: new Date("2025-02-23T13:39:52.249Z"), } - export const Polar = { - teamID: Id("team"), - timeSeen: new Date("2025-02-23T13:39:52.249Z"), + export const Team = { + id: Id("team"), + name: "John Does' Team", + slug: "john_doe", + subscriptions: [Subscription], + members: [Member] } export const Machine = { diff --git a/packages/core/src/member/index.ts b/packages/core/src/member/index.ts index 0f64436a..1e2cedec 100644 --- a/packages/core/src/member/index.ts +++ b/packages/core/src/member/index.ts @@ -6,7 +6,7 @@ import { Common } from "../common"; import { createID, fn } from "../utils"; import { createEvent } from "../event"; import { Examples } from "../examples"; -import { memberTable } from "./member.sql"; +import { memberTable, role } from "./member.sql"; import { and, eq, sql, asc, isNull } from "../drizzle"; import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction"; @@ -17,7 +17,7 @@ export namespace Member { description: Common.IdDescription, example: Examples.Member.id, }), - timeSeen: z.date().or(z.null()).openapi({ + timeSeen: z.date().nullable().or(z.undefined()).openapi({ description: "The last time this team member was active", example: Examples.Member.timeSeen }), @@ -25,6 +25,10 @@ export namespace Member { description: "The unique id of the team this member is on", example: Examples.Member.teamID }), + role: z.enum(role).openapi({ + description: "The role of this team member", + example: Examples.Member.role + }), email: z.string().openapi({ description: "The email of this team member", example: Examples.Member.email @@ -68,6 +72,7 @@ export namespace Member { id, teamID: useTeam(), email: input.email, + role: input.first ? "owner" : "member", timeSeen: input.first ? sql`now()` : null, }) @@ -113,11 +118,18 @@ export namespace Member { ), ) + /** + * Converts a raw member database row into a standardized {@link Member.Info} object. + * + * @param input - The database row representing a member. + * @returns The member information formatted as a {@link Member.Info} object. + */ export function serialize( input: typeof memberTable.$inferSelect, ): z.infer { return { id: input.id, + role: input.role, email: input.email, teamID: input.teamID, timeSeen: input.timeSeen diff --git a/packages/core/src/member/member.sql.ts b/packages/core/src/member/member.sql.ts index cb19ef89..6b0acfc8 100644 --- a/packages/core/src/member/member.sql.ts +++ b/packages/core/src/member/member.sql.ts @@ -1,12 +1,15 @@ import { teamIndexes } from "../team/team.sql"; import { timestamps, utc, teamID } from "../drizzle/types"; -import { index, pgTable, uniqueIndex, varchar } from "drizzle-orm/pg-core"; +import { index, pgTable, text, uniqueIndex, varchar } from "drizzle-orm/pg-core"; + +export const role = ["admin", "member", "owner"] as const; export const memberTable = pgTable( "member", { ...teamID, ...timestamps, + role: text("role", { enum: role }).notNull(), timeSeen: utc("time_seen"), email: varchar("email", { length: 255 }).notNull(), }, diff --git a/packages/core/src/polar/index.ts b/packages/core/src/polar/index.ts index fb406b75..a462c715 100644 --- a/packages/core/src/polar/index.ts +++ b/packages/core/src/polar/index.ts @@ -1,69 +1,16 @@ import { z } from "zod"; import { fn } from "../utils"; import { Resource } from "sst"; -import { eq, and } from "../drizzle"; -import { useTeam } from "../actor"; -import { createEvent } from "../event"; -// import { polarTable, Standing } from "./polar.sql.ts.test"; +import { useTeam, useUserID } from "../actor"; import { Polar as PolarSdk } from "@polar-sh/sdk"; -import { useTransaction } from "../drizzle/transaction"; +import { validateEvent } from "@polar-sh/sdk/webhooks"; +import { PlanType } from "../subscription/subscription.sql"; const polar = new PolarSdk({ accessToken: Resource.PolarSecret.value, server: Resource.App.stage !== "production" ? "sandbox" : "production" }); - +const planType = z.enum(PlanType) export namespace Polar { export const client = polar; - export const Info = z.object({ - teamID: z.string(), - subscriptionID: z.string().nullable(), - customerID: z.string(), - subscriptionItemID: z.string().nullable(), - // standing: z.enum(Standing), - }); - - export type Info = z.infer; - - export const Checkout = z.object({ - annual: z.boolean().optional(), - successUrl: z.string(), - cancelUrl: z.string(), - }); - - export const CheckoutSession = z.object({ - url: z.string().nullable(), - }); - - export const CustomerSubscriptionEventType = [ - "created", - "updated", - "deleted", - ] as const; - - export const Events = { - CustomerSubscriptionEvent: createEvent( - "polar.customer-subscription-event", - z.object({ - type: z.enum(CustomerSubscriptionEventType), - status: z.string(), - teamID: z.string().min(1), - customerID: z.string().min(1), - subscriptionID: z.string().min(1), - subscriptionItemID: z.string().min(1), - }), - ), - }; - - // export function get() { - // return useTransaction(async (tx) => - // tx - // .select() - // .from(polarTable) - // .where(eq(polarTable.teamID, useTeam())) - // .execute() - // .then((rows) => rows.map(serialize).at(0)), - // ); - // } - export const fromUserEmail = fn(z.string().min(1), async (email) => { try { const customers = await client.customers.list({ email }) @@ -81,89 +28,69 @@ export namespace Polar { } }) - // export const setCustomerID = fn(Info.shape.customerID, async (customerID) => - // useTransaction(async (tx) => - // tx - // .insert(polarTable) - // .values({ - // teamID: useTeam(), - // customerID, - // standing: "new", - // }) - // .execute(), - // ), - // ); + const getProductIDs = (plan: z.infer) => { + switch (plan) { + case "free": + return [Resource.NestriFreeMonthly.value] + case "pro": + return [Resource.NestriProYearly.value, Resource.NestriProMonthly.value] + case "family": + return [Resource.NestriFamilyYearly.value, Resource.NestriFamilyMonthly.value] + default: + return [Resource.NestriFreeMonthly.value] + } + } - // export const setSubscription = fn( - // Info.pick({ - // subscriptionID: true, - // subscriptionItemID: true, - // }), - // (input) => - // useTransaction(async (tx) => - // tx - // .update(polarTable) - // .set({ - // subscriptionID: input.subscriptionID, - // subscriptionItemID: input.subscriptionItemID, - // }) - // .where(eq(polarTable.teamID, useTeam())) - // .returning() - // .execute() - // .then((rows) => rows.map(serialize).at(0)), - // ), - // ); + export const createPortal = fn( + z.string(), + async (customerId) => { + const session = await client.customerSessions.create({ + customerId + }) - // export const removeSubscription = fn( - // z.string().min(1), - // (stripeSubscriptionID) => - // useTransaction((tx) => - // tx - // .update(polarTable) - // .set({ - // subscriptionItemID: null, - // subscriptionID: null, - // }) - // .where(and(eq(polarTable.subscriptionID, stripeSubscriptionID))) - // .execute(), - // ), - // ); + return session.customerPortalUrl + } + ) - // export const setStanding = fn( - // Info.pick({ - // subscriptionID: true, - // standing: true, - // }), - // (input) => - // useTransaction((tx) => - // tx - // .update(polarTable) - // .set({ standing: input.standing }) - // .where(and(eq(polarTable.subscriptionID, input.subscriptionID!))) - // .execute(), - // ), - // ); + //TODO: Implement this + export const handleWebhook = async(payload: ReturnType) => { + switch (payload.type) { + case "subscription.created": + const teamID = payload.data.metadata.teamID + } + } - // export const fromCustomerID = fn(Info.shape.customerID, (customerID) => - // useTransaction((tx) => - // tx - // .select() - // .from(polarTable) - // .where(and(eq(polarTable.customerID, customerID))) - // .execute() - // .then((rows) => rows.map(serialize).at(0)), - // ), - // ); + export const createCheckout = fn( + z + .object({ + planType: z.enum(PlanType), + customerEmail: z.string(), + successUrl: z.string(), + customerID: z.string(), + allowDiscountCodes: z.boolean(), + teamID: z.string() + }) + .partial({ + customerEmail: true, + allowDiscountCodes: true, + customerID: true, + teamID: true + }), + async (input) => { + const productIDs = getProductIDs(input.planType) - // function serialize( - // input: typeof polarTable.$inferSelect, - // ): z.infer { - // return { - // teamID: input.teamID, - // customerID: input.customerID, - // subscriptionID: input.subscriptionID, - // subscriptionItemID: input.subscriptionItemID, - // standing: input.standing, - // }; - // } + const checkoutUrl = + await client.checkouts.create({ + products: productIDs, + customerEmail: input.customerEmail ?? useUserID(), + successUrl: `${input.successUrl}?checkout={CHECKOUT_ID}`, + allowDiscountCodes: input.allowDiscountCodes ?? false, + customerId: input.customerID, + customerMetadata: { + teamID: input.teamID ?? useTeam() + } + }) + + return checkoutUrl.url + }) } \ No newline at end of file diff --git a/packages/core/src/polar/polar.sql.ts.test b/packages/core/src/polar/polar.sql.ts.test deleted file mode 100644 index b98de4e3..00000000 --- a/packages/core/src/polar/polar.sql.ts.test +++ /dev/null @@ -1,23 +0,0 @@ -import { timestamps, teamID } from "../drizzle/types"; -import { teamIndexes, teamTable } from "../team/team.sql"; -import { pgTable, text, varchar } from "drizzle-orm/pg-core"; - -// FIXME: This is causing errors while trying to db push -export const Standing = ["new", "good", "overdue"] as const; - -export const polarTable = pgTable( - "polar", - { - teamID: teamID.teamID.primaryKey().references(() => teamTable.id), - ...timestamps, - customerID: varchar("customer_id", { length: 255 }).notNull(), - subscriptionID: varchar("subscription_id", { length: 255 }), - subscriptionItemID: varchar("subscription_item_id", { - length: 255, - }), - standing: text("standing", { enum: Standing }).notNull(), - }, - (table) => [ - ...teamIndexes(table), - ] -) \ No newline at end of file diff --git a/packages/core/src/steam/steam.sql.ts b/packages/core/src/steam/steam.sql.ts index 715d4456..e9193871 100644 --- a/packages/core/src/steam/steam.sql.ts +++ b/packages/core/src/steam/steam.sql.ts @@ -1,24 +1,7 @@ import { z } from "zod"; -import { id, timestamps, ulid, userID, utc } from "../drizzle/types"; -import { index, pgTable, integer, uniqueIndex, varchar, text, primaryKey, json } from "drizzle-orm/pg-core"; import { userTable } from "../user/user.sql"; - - -// public string Username { get; set; } = string.Empty; -// public ulong SteamId { get; set; } -// public string Email { get; set; } = string.Empty; -// public string Country { get; set; } = string.Empty; -// public string PersonaName { get; set; } = string.Empty; -// public string AvatarUrl { get; set; } = string.Empty; -// public bool IsLimited { get; set; } -// public bool IsLocked { get; set; } -// public bool IsBanned { get; set; } -// public bool IsAllowedToInviteFriends { get; set; } -// public ulong GameId { get; set; } -// public string GamePlayingName { get; set; } = string.Empty; -// public DateTime LastLogOn { get; set; } -// public DateTime LastLogOff { get; set; } -// public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +import { id, timestamps, ulid, utc } from "../drizzle/types"; +import { index, pgTable, integer, uniqueIndex, varchar, text, json } from "drizzle-orm/pg-core"; export const LastGame = z.object({ gameID: z.number(), @@ -54,5 +37,9 @@ export const steamTable = pgTable( steamEmail: varchar("steam_email", { length: 255 }).notNull(), personaName: varchar("persona_name", { length: 255 }).notNull(), limitation: json("limitation").$type().notNull(), - } + }, + (table) => [ + uniqueIndex("steam_id").on(table.steamID), + index("steam_user_id").on(table.userID), + ], ); \ No newline at end of file diff --git a/packages/core/src/subscription/index.ts b/packages/core/src/subscription/index.ts new file mode 100644 index 00000000..4deab2c6 --- /dev/null +++ b/packages/core/src/subscription/index.ts @@ -0,0 +1,192 @@ +import { z } from "zod"; +import { Common } from "../common"; +import { Examples } from "../examples"; +import { createID, fn } from "../utils"; +import { eq, and, isNull } from "../drizzle"; +import { useTeam, useUserID } from "../actor"; +import { createTransaction, useTransaction } from "../drizzle/transaction"; +import { PlanType, Standing, subscriptionTable } from "./subscription.sql"; + +export namespace Subscription { + export const Info = z.object({ + id: z.string().openapi({ + description: Common.IdDescription, + example: Examples.Subscription.id, + }), + polarSubscriptionID: z.string().nullable().or(z.undefined()).openapi({ + description: "The unique id of the plan this subscription is on", + example: Examples.Subscription.polarSubscriptionID, + }), + teamID: z.string().openapi({ + description: "The unique id of the team this subscription is for", + example: Examples.Subscription.teamID, + }), + userID: z.string().openapi({ + description: "The unique id of the user who is paying this subscription", + example: Examples.Subscription.userID, + }), + polarProductID: z.string().nullable().or(z.undefined()).openapi({ + description: "The unique id of the product this subscription is for", + example: Examples.Subscription.polarProductID, + }), + tokens: z.number().openapi({ + description: "The number of tokens this subscription has left", + example: Examples.Subscription.tokens, + }), + planType: z.enum(PlanType).openapi({ + description: "The type of plan this subscription is for", + example: Examples.Subscription.planType, + }), + standing: z.enum(Standing).openapi({ + description: "The standing of this subscription", + example: Examples.Subscription.standing, + }), + }).openapi({ + ref: "Subscription", + description: "Represents a subscription on Nestri", + example: Examples.Subscription + }); + + export type Info = z.infer; + + export const create = fn( + Info + .partial({ + teamID: true, + userID: true, + id: true, + standing: true, + planType: true, + polarProductID: true, + polarSubscriptionID: true, + }), + (input) => + createTransaction(async (tx) => { + const id = input.id ?? createID("subscription"); + + await tx.insert(subscriptionTable).values({ + id, + tokens: input.tokens, + polarProductID: input.polarProductID ?? null, + polarSubscriptionID: input.polarSubscriptionID ?? null, + standing: input.standing ?? "new", + planType: input.planType ?? "free", + userID: input.userID ?? useUserID(), + teamID: input.teamID ?? useTeam(), + }); + + return id; + }) + ) + + export const setPolarProductID = fn( + Info.pick({ + id: true, + polarProductID: true, + }), + (input) => + useTransaction(async (tx) => + tx.update(subscriptionTable) + .set({ + polarProductID: input.polarProductID, + }) + .where(eq(subscriptionTable.id, input.id)) + ) + ) + + export const setPolarSubscriptionID = fn( + Info.pick({ + id: true, + polarSubscriptionID: true, + }), + (input) => + useTransaction(async (tx) => + tx.update(subscriptionTable) + .set({ + polarSubscriptionID: input.polarSubscriptionID, + }) + .where(eq(subscriptionTable.id, input.id)) + ) + ) + + export const fromID = fn(z.string(), async (id) => + useTransaction(async (tx) => + tx + .select() + .from(subscriptionTable) + .where( + and( + eq(subscriptionTable.id, id), + isNull(subscriptionTable.timeDeleted) + ) + ) + .orderBy(subscriptionTable.timeCreated) + .then((rows) => rows.map(serialize)) + ) + ) + export const fromTeamID = fn(z.string(), async (teamID) => + useTransaction(async (tx) => + tx + .select() + .from(subscriptionTable) + .where( + and( + eq(subscriptionTable.teamID, teamID), + isNull(subscriptionTable.timeDeleted) + ) + ) + .orderBy(subscriptionTable.timeCreated) + .then((rows) => rows.map(serialize)) + ) + ) + + export const fromUserID = fn(z.string(), async (userID) => + useTransaction(async (tx) => + tx + .select() + .from(subscriptionTable) + .where( + and( + eq(subscriptionTable.userID, userID), + isNull(subscriptionTable.timeDeleted) + ) + ) + .orderBy(subscriptionTable.timeCreated) + .then((rows) => rows.map(serialize)) + ) + ) + export const remove = fn(Info.shape.id, (id) => + useTransaction(async (tx) => + tx + .update(subscriptionTable) + .set({ + timeDeleted: Common.now(), + }) + .where(eq(subscriptionTable.id, id)) + .execute() + ) + ) + + /** + * Converts a raw subscription database record into a structured {@link Info} object. + * + * @param input - The subscription record retrieved from the database. + * @returns The subscription data formatted according to the {@link Info} schema. + */ + export function serialize( + input: typeof subscriptionTable.$inferSelect + ): z.infer { + return { + id: input.id, + userID: input.userID, + teamID: input.teamID, + standing: input.standing, + planType: input.planType, + tokens: input.tokens, + polarProductID: input.polarProductID, + polarSubscriptionID: input.polarSubscriptionID, + }; + } + + +} \ No newline at end of file diff --git a/packages/core/src/subscription/subscription.sql.ts b/packages/core/src/subscription/subscription.sql.ts new file mode 100644 index 00000000..a630d0e4 --- /dev/null +++ b/packages/core/src/subscription/subscription.sql.ts @@ -0,0 +1,31 @@ +import { teamTable } from "../team/team.sql"; +import { ulid, userID, timestamps } from "../drizzle/types"; +import { index, integer, pgTable, primaryKey, text, uniqueIndex, varchar } from "drizzle-orm/pg-core"; + +export const Standing = ["new", "good", "overdue", "cancelled"] as const; +export const PlanType = ["free", "pro", "family", "enterprise"] as const; + +export const subscriptionTable = pgTable( + "subscription", + { + ...userID, + ...timestamps, + teamID: ulid("team_id") + .references(() => teamTable.id, { onDelete: "cascade" }) + .notNull(), + standing: text("standing", { enum: Standing }) + .notNull(), + planType: text("plan_type", { enum: PlanType }) + .notNull(), + tokens: integer("tokens").notNull(), + polarProductID: varchar("product_id", { length: 255 }), + polarSubscriptionID: varchar("subscription_id", { length: 255 }), + }, + (table) => [ + uniqueIndex("subscription_id").on(table.id), + index("subscription_user_id").on(table.userID), + primaryKey({ + columns: [table.id, table.teamID] + }), + ] +) \ No newline at end of file diff --git a/packages/core/src/team/index.ts b/packages/core/src/team/index.ts index 9b63fd8c..e8798922 100644 --- a/packages/core/src/team/index.ts +++ b/packages/core/src/team/index.ts @@ -1,16 +1,18 @@ import { z } from "zod"; -import { Resource } from "sst"; -import { bus } from "sst/aws/bus"; import { Common } from "../common"; +import { Member } from "../member"; +import { teamTable } from "./team.sql"; import { Examples } from "../examples"; +import { assertActor } from "../actor"; import { createEvent } from "../event"; import { createID, fn } from "../utils"; +import { Subscription } from "../subscription"; import { and, eq, sql, isNull } from "../drizzle"; -import { PlanType, teamTable } from "./team.sql"; -import { assertActor, withActor } from "../actor"; import { memberTable } from "../member/member.sql"; import { ErrorCodes, VisibleError } from "../error"; -import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction"; +import { groupBy, map, pipe, values } from "remeda"; +import { subscriptionTable } from "../subscription/subscription.sql"; +import { createTransaction, useTransaction } from "../drizzle/transaction"; export namespace Team { export const Info = z @@ -19,6 +21,7 @@ export namespace Team { description: Common.IdDescription, example: Examples.Team.id, }), + // Remove spaces and make sure it is lowercase (this is just to make sure the frontend did this) slug: z.string().regex(/^[a-z0-9\-]+$/, "Use a URL friendly name.").openapi({ description: "The unique and url-friendly slug of this team", example: Examples.Team.slug @@ -27,10 +30,14 @@ export namespace Team { description: "The name of this team", example: Examples.Team.name }), - planType: z.enum(PlanType).openapi({ - description: "The type of Plan this team is subscribed to", - example: Examples.Team.planType - }) + members: Member.Info.array().openapi({ + description: "The members of this team", + example: Examples.Team.members + }), + subscriptions: Subscription.Info.array().openapi({ + description: "The subscriptions of this team", + example: Examples.Team.subscriptions + }), }) .openapi({ ref: "Team", @@ -60,16 +67,14 @@ export namespace Team { } export const create = fn( - Info.pick({ slug: true, id: true, name: true, planType: true }).partial({ + Info.pick({ slug: true, id: true, name: true, }).partial({ id: true, }), (input) => createTransaction(async (tx) => { const id = input.id ?? createID("team"); const result = await tx.insert(teamTable).values({ id, - //Remove spaces and make sure it is lowercase (this is just to make sure the frontend did this) - slug: input.slug, //.toLowerCase().replace(/[\s]/g, ''), - planType: input.planType, + slug: input.slug, name: input.name }) .onConflictDoNothing({ target: teamTable.slug }) @@ -80,6 +85,7 @@ export namespace Team { }) ) + //TODO: "Delete" subscription and member(s) as well export const remove = fn(Info.shape.id, (input) => useTransaction(async (tx) => { const account = assertActor("user"); @@ -106,48 +112,107 @@ export namespace Team { }), ); - export const list = fn(z.void(), () => - useTransaction((tx) => + export const list = fn(z.void(), () => { + const actor = assertActor("user"); + return useTransaction(async (tx) => tx .select() .from(teamTable) - .where(isNull(teamTable.timeDeleted)) + .leftJoin(subscriptionTable, eq(subscriptionTable.teamID, teamTable.id)) + .innerJoin(memberTable, eq(memberTable.teamID, teamTable.id)) + .where( + and( + eq(memberTable.email, actor.properties.email), + isNull(memberTable.timeDeleted), + isNull(teamTable.timeDeleted), + ), + ) .execute() - .then((rows) => rows.map(serialize)), + .then((rows) => serialize(rows)) + ) + }); + + export const fromID = fn(z.string().min(1), async (id) => + useTransaction(async (tx) => + tx + .select() + .from(teamTable) + .leftJoin(subscriptionTable, eq(subscriptionTable.teamID, teamTable.id)) + .innerJoin(memberTable, eq(memberTable.teamID, teamTable.id)) + .where( + and( + eq(teamTable.id, id), + isNull(memberTable.timeDeleted), + isNull(teamTable.timeDeleted), + ), + ) + .execute() + .then((rows) => serialize(rows).at(0)) ), ); - export const fromID = fn(z.string().min(1), async (id) => - useTransaction(async (tx) => { - return tx + export const fromSlug = fn(z.string().min(1), async (slug) => + useTransaction(async (tx) => + tx .select() .from(teamTable) - .where(and(eq(teamTable.id, id), isNull(teamTable.timeDeleted))) + .leftJoin(subscriptionTable, eq(subscriptionTable.teamID, teamTable.id)) + .innerJoin(memberTable, eq(memberTable.teamID, teamTable.id)) + .where( + and( + eq(teamTable.slug, slug), + isNull(memberTable.timeDeleted), + isNull(teamTable.timeDeleted), + ), + ) .execute() - .then((rows) => rows.map(serialize).at(0)) - }), - ); - - export const fromSlug = fn(z.string().min(1), async (input) => - useTransaction(async (tx) => { - return tx - .select() - .from(teamTable) - .where(and(eq(teamTable.slug, input), isNull(teamTable.timeDeleted))) - .execute() - .then((rows) => rows.map(serialize).at(0)) - }), + .then((rows) => serialize(rows).at(0)) + ), ); + /** + * Transforms an array of team, subscription, and member records into structured team objects. + * + * Groups input rows by team ID and constructs an array of team objects, each including its associated members and subscriptions. + * + * @param input - Array of objects containing team, subscription, and member data. + * @returns An array of team objects with their members and subscriptions. + */ export function serialize( - input: typeof teamTable.$inferSelect, - ): z.infer { - return { - id: input.id, - name: input.name, - slug: input.slug, - planType: input.planType, - }; + input: { team: typeof teamTable.$inferSelect, subscription: typeof subscriptionTable.$inferInsert | null, member: typeof memberTable.$inferInsert | null }[], + ): z.infer[] { + console.log("serialize", input) + return pipe( + input, + groupBy((row) => row.team.id), + values(), + map((group) => ({ + name: group[0].team.name, + id: group[0].team.id, + slug: group[0].team.slug, + subscriptions: !group[0].subscription ? + [] : + group.map((row) => ({ + planType: row.subscription!.planType, + polarProductID: row.subscription!.polarProductID, + polarSubscriptionID: row.subscription!.polarSubscriptionID, + standing: row.subscription!.standing, + tokens: row.subscription!.tokens, + teamID: row.subscription!.teamID, + userID: row.subscription!.userID, + id: row.subscription!.id, + })), + members: + !group[0].member ? + [] : + group.map((row) => ({ + id: row.member!.id, + email: row.member!.email, + role: row.member!.role, + teamID: row.member!.teamID, + timeSeen: row.member!.timeSeen, + })) + })), + ); } - } \ No newline at end of file diff --git a/packages/core/src/team/team.sql.ts b/packages/core/src/team/team.sql.ts index 85b54f2e..1281817d 100644 --- a/packages/core/src/team/team.sql.ts +++ b/packages/core/src/team/team.sql.ts @@ -1,15 +1,11 @@ -import { } from "drizzle-orm/postgres-js"; import { timestamps, id } from "../drizzle/types"; import { varchar, pgTable, primaryKey, uniqueIndex, - text } from "drizzle-orm/pg-core"; -export const PlanType = ["Hosted", "BYOG"] as const; - export const teamTable = pgTable( "team", { @@ -17,7 +13,6 @@ export const teamTable = pgTable( ...timestamps, name: varchar("name", { length: 255 }).notNull(), slug: varchar("slug", { length: 255 }).notNull(), - planType: text("plan_type", { enum: PlanType }).notNull() }, (table) => [ uniqueIndex("slug").on(table.slug) diff --git a/packages/core/src/user/index.ts b/packages/core/src/user/index.ts index af0c845b..362742b2 100644 --- a/packages/core/src/user/index.ts +++ b/packages/core/src/user/index.ts @@ -1,21 +1,22 @@ import { z } from "zod"; import { Team } from "../team"; import { bus } from "sst/aws/bus"; +import { Steam } from "../steam"; import { Common } from "../common"; import { Polar } from "../polar/index"; import { createID, fn } from "../utils"; import { userTable } from "./user.sql"; import { createEvent } from "../event"; -import { pipe, groupBy, values, map } from "remeda"; import { Examples } from "../examples"; import { Resource } from "sst/resource"; import { teamTable } from "../team/team.sql"; import { steamTable } from "../steam/steam.sql"; import { assertActor, withActor } from "../actor"; import { memberTable } from "../member/member.sql"; -import { and, eq, isNull, asc, getTableColumns, sql } from "../drizzle"; +import { pipe, groupBy, values, map } from "remeda"; +import { and, eq, isNull, asc, sql } from "../drizzle"; +import { subscriptionTable } from "../subscription/subscription.sql"; import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction"; -import { Steam } from "../steam"; export namespace User { @@ -154,91 +155,27 @@ export namespace User { }) export const fromEmail = fn(z.string(), async (email) => - useTransaction(async (tx) => { - const rows = await tx + useTransaction(async (tx) => + tx .select() .from(userTable) .leftJoin(steamTable, eq(userTable.id, steamTable.userID)) .where(and(eq(userTable.email, email), isNull(userTable.timeDeleted))) .orderBy(asc(userTable.timeCreated)) - - const result = pipe( - rows, - groupBy((row) => row.user.id), - values(), - map( - (group): Info => ({ - id: group[0].user.id, - name: group[0].user.name, - email: group[0].user.email, - avatarUrl: group[0].user.avatarUrl, - discriminator: group[0].user.discriminator, - polarCustomerID: group[0].user.polarCustomerID, - steamAccounts: !group[0].steam ? - [] : - group.map((row) => ({ - id: row.steam!.id, - userID: row.steam!.userID, - steamID: row.steam!.steamID, - lastSeen: row.steam!.lastSeen, - avatarUrl: row.steam!.avatarUrl, - lastGame: row.steam!.lastGame, - username: row.steam!.username, - countryCode: row.steam!.countryCode, - steamEmail: row.steam!.steamEmail, - personaName: row.steam!.personaName, - limitation: row.steam!.limitation, - })), - }) - ) - ) - - return result[0] - }), + .then((rows => serialize(rows).at(0))) + ) ) - export const fromID = fn(z.string(), async (id) => - useTransaction(async (tx) => { - const rows = await tx + export const fromID = fn(z.string(), (id) => + useTransaction(async (tx) => + tx .select() .from(userTable) .leftJoin(steamTable, eq(userTable.id, steamTable.userID)) - .where(and(eq(userTable.id, id), isNull(userTable.timeDeleted))) + .where(and(eq(userTable.id, id), isNull(userTable.timeDeleted), isNull(steamTable.timeDeleted))) .orderBy(asc(userTable.timeCreated)) - - const result = pipe( - rows, - groupBy((row) => row.user.id), - values(), - map( - (group): Info => ({ - id: group[0].user.id, - name: group[0].user.name, - email: group[0].user.email, - avatarUrl: group[0].user.avatarUrl, - discriminator: group[0].user.discriminator, - polarCustomerID: group[0].user.polarCustomerID, - steamAccounts: !group[0].steam ? - [] : - group.map((row) => ({ - id: row.steam!.id, - userID: row.steam!.userID, - steamID: row.steam!.steamID, - lastSeen: row.steam!.lastSeen, - avatarUrl: row.steam!.avatarUrl, - lastGame: row.steam!.lastGame, - username: row.steam!.username, - countryCode: row.steam!.countryCode, - steamEmail: row.steam!.steamEmail, - personaName: row.steam!.personaName, - limitation: row.steam!.limitation, - })), - }) - ) - ) - - return result[0] - }), + .then((rows) => serialize(rows).at(0)) + ), ) export const remove = fn(Info.shape.id, (id) => @@ -254,12 +191,54 @@ export namespace User { }), ); + /** + * Converts an array of user and Steam account records into structured user objects with associated Steam accounts. + * + * @param input - An array of objects containing user data and optional Steam account data. + * @returns An array of user objects, each including a list of their associated Steam accounts. + */ + export function serialize( + input: { user: typeof userTable.$inferSelect; steam: typeof steamTable.$inferSelect | null }[], + ): z.infer[] { + return pipe( + input, + groupBy((row) => row.user.id), + values(), + map((group) => ({ + ...group[0].user, + steamAccounts: !group[0].steam ? + [] : + group.map((row) => ({ + id: row.steam!.id, + lastSeen: row.steam!.lastSeen, + countryCode: row.steam!.countryCode, + username: row.steam!.username, + steamID: row.steam!.steamID, + lastGame: row.steam!.lastGame, + limitation: row.steam!.limitation, + steamEmail: row.steam!.steamEmail, + userID: row.steam!.userID, + personaName: row.steam!.personaName, + avatarUrl: row.steam!.avatarUrl, + })), + })), + ) + } + + /** + * Retrieves the list of teams that the current user belongs to. + * + * @returns An array of team information objects representing the user's active team memberships. + * + * @remark Only teams and memberships that have not been deleted are included in the result. + */ export function teams() { const actor = assertActor("user"); - return useTransaction((tx) => + return useTransaction(async (tx) => tx - .select(getTableColumns(teamTable)) + .select() .from(teamTable) + .leftJoin(subscriptionTable, eq(subscriptionTable.teamID, teamTable.id)) .innerJoin(memberTable, eq(memberTable.teamID, teamTable.id)) .where( and( @@ -269,7 +248,7 @@ export namespace User { ), ) .execute() - .then((rows) => rows.map(Team.serialize)) - ); + .then((rows) => Team.serialize(rows)) + ) } } \ No newline at end of file diff --git a/packages/core/src/utils/id.ts b/packages/core/src/utils/id.ts index ecc71b96..09b9832f 100644 --- a/packages/core/src/utils/id.ts +++ b/packages/core/src/utils/id.ts @@ -2,11 +2,14 @@ import { ulid } from "ulid"; export const prefixes = { user: "usr", - team: "tea", + team: "tem", task: "tsk", machine: "mch", member: "mbr", steam: "stm", + subscription: "sub", + invite: "inv", + product: "prd", } as const; /** diff --git a/packages/functions/src/api/auth.ts b/packages/functions/src/api/auth.ts index 23b3e81b..1fa21177 100644 --- a/packages/functions/src/api/auth.ts +++ b/packages/functions/src/api/auth.ts @@ -42,11 +42,6 @@ export const auth: MiddlewareHandler = async (c, next) => { "Invalid bearer token", ); } - - if (result.subject.type === "machine") { - console.log("machine detected") - return withActor(result.subject, next); - } if (result.subject.type === "user") { const teamID = c.req.header("x-nestri-team"); @@ -58,12 +53,11 @@ export const auth: MiddlewareHandler = async (c, next) => { teamID, }, }, - async () => { - return withActor( + async () => + withActor( result.subject, next, - ); - }, + ) ); } }; \ No newline at end of file diff --git a/packages/functions/src/api/index.ts b/packages/functions/src/api/index.ts index d0683058..3ec536d5 100644 --- a/packages/functions/src/api/index.ts +++ b/packages/functions/src/api/index.ts @@ -3,7 +3,7 @@ import { Hono } from "hono"; import { auth } from "./auth"; import { cors } from "hono/cors"; import { TeamApi } from "./team"; -import { SteamApi } from "./steam"; +import { PolarApi } from "./polar"; import { logger } from "hono/logger"; import { Realtime } from "./realtime"; import { AccountApi } from "./account"; @@ -27,7 +27,7 @@ const routes = app .get("/", (c) => c.text("Hello World!")) .route("/realtime", Realtime.route) .route("/team", TeamApi.route) - .route("/steam", SteamApi.route) + .route("/polar", PolarApi.route) .route("/account", AccountApi.route) .route("/machine", MachineApi.route) .onError((error, c) => { diff --git a/packages/functions/src/api/polar.ts b/packages/functions/src/api/polar.ts new file mode 100644 index 00000000..0c1a5706 --- /dev/null +++ b/packages/functions/src/api/polar.ts @@ -0,0 +1,174 @@ +import { z } from "zod"; +import { Hono } from "hono"; +import { Resource } from "sst"; +import { notPublic } from "./auth"; +import { describeRoute } from "hono-openapi"; +import { User } from "@nestri/core/user/index"; +import { assertActor } from "@nestri/core/actor"; +import { Polar } from "@nestri/core/polar/index"; +import { Examples } from "@nestri/core/examples"; +import { ErrorResponses, Result, validator } from "./common"; +import { ErrorCodes, VisibleError } from "@nestri/core/error"; +import { PlanType } from "@nestri/core/subscription/subscription.sql"; +import { WebhookVerificationError, validateEvent } from "@polar-sh/sdk/webhooks"; + +export namespace PolarApi { + export const route = new Hono() + .use(notPublic) + .get("/", + describeRoute({ + tags: ["Polar"], + summary: "Create a Polar.sh customer portal", + description: "Creates Polar.sh's customer portal url where the user can manage their payments", + responses: { + 200: { + content: { + "application/json": { + schema: Result( + z.object({ + portalUrl: z.string() + }).openapi({ + description: "The customer portal url", + example: { portalUrl: "https://polar.sh/portal/39393jdie09292" } + }) + ), + }, + }, + description: "customer portal url" + }, + 400: ErrorResponses[400], + 404: ErrorResponses[404], + 429: ErrorResponses[429], + } + }), + async (c) => { + const actor = assertActor("user"); + + const user = await User.fromID(actor.properties.userID); + + if (!user) + throw new VisibleError( + "not_found", + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + "User not found", + ); + + if (!user.polarCustomerID) + throw new VisibleError( + "not_found", + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + "User does not contain Polar customer ID" + ) + + const portalUrl = await Polar.createPortal(user.polarCustomerID) + + return c.json({ + data: { + portalUrl + } + }) + } + ) + .post("/checkout", + describeRoute({ + tags: ["Polar"], + summary: "Create a checkout url", + description: "Creates a Polar.sh's checkout url for the user to pay a subscription for this team", + responses: { + 200: { + content: { + "application/json": { + schema: Result( + z.object({ + checkoutUrl: z.string() + }).openapi({ + description: "The checkout url", + example: { checkoutUrl: "https://polar.sh/portal/39393jdie09292" } + }) + ), + }, + }, + description: "checkout url" + }, + 400: ErrorResponses[400], + 404: ErrorResponses[404], + 429: ErrorResponses[429], + } + }), + validator( + "json", + z + .object({ + planType: z.enum(PlanType), + successUrl: z.string().url("Success url must be a valid url") + }) + .openapi({ + description: "Details of the team to create", + example: { + planType: Examples.Subscription.planType, + successUrl: "https://your-url.io/thanks" + }, + }) + ), + async (c) => { + const body = c.req.valid("json"); + const actor = assertActor("user"); + + const user = await User.fromID(actor.properties.userID); + + if (!user) + throw new VisibleError( + "not_found", + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + "User not found", + ); + + if (!user.polarCustomerID) + throw new VisibleError( + "not_found", + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + "User does not contain Polar customer ID" + ) + + const checkoutUrl = await Polar.createCheckout({ customerID: user.polarCustomerID, planType: body.planType, successUrl: body.successUrl }) + + return c.json({ + data: { + checkoutUrl, + } + }) + } + ) + .post("/webhook", + async (c) => { + const requestBody = await c.req.text(); + + const webhookSecret = Resource.PolarWebhookSecret.value + + const webhookHeaders = { + "webhook-id": c.req.header("webhook-id") ?? "", + "webhook-timestamp": c.req.header("webhook-timestamp") ?? "", + "webhook-signature": c.req.header("webhook-signature") ?? "", + }; + + let webhookPayload: ReturnType; + try { + webhookPayload = validateEvent( + requestBody, + webhookHeaders, + webhookSecret, + ); + } catch (error) { + if (error instanceof WebhookVerificationError) { + return c.json({ received: false }, { status: 403 }); + } + + throw error; + } + + await Polar.handleWebhook(webhookPayload) + + return c.json({ received: true }); + } + ) +} \ No newline at end of file diff --git a/packages/functions/src/api/subscription.ts b/packages/functions/src/api/subscription.ts new file mode 100644 index 00000000..d6614c58 --- /dev/null +++ b/packages/functions/src/api/subscription.ts @@ -0,0 +1,46 @@ +import { Hono } from "hono"; +import { notPublic } from "./auth"; +import { describeRoute } from "hono-openapi"; +import { Examples } from "@nestri/core/examples"; +import { assertActor } from "@nestri/core/actor"; +import { ErrorResponses, Result } from "./common"; +import { Subscription } from "@nestri/core/subscription/index"; + +export namespace SubscriptionApi { + export const route = new Hono() + .use(notPublic) + .get("/", + describeRoute({ + tags: ["Subscription"], + summary: "Get user subscriptions", + description: "Get all user subscriptions", + responses: { + 200: { + content: { + "application/json": { + schema: Result( + Subscription.Info.array().openapi({ + description: "All the subscriptions this user has", + example: [Examples.Subscription] + }) + ), + }, + }, + description: "All user subscriptions" + }, + 400: ErrorResponses[400], + 404: ErrorResponses[404], + 429: ErrorResponses[429], + } + }), + async (c) => { + const actor = assertActor("user") + + const subscriptions = await Subscription.fromUserID(actor.properties.userID) + + return c.json({ + data: subscriptions + }) + } + ) +} \ No newline at end of file diff --git a/packages/functions/src/api/team.ts b/packages/functions/src/api/team.ts index 6072fbf5..da10ece9 100644 --- a/packages/functions/src/api/team.ts +++ b/packages/functions/src/api/team.ts @@ -5,9 +5,12 @@ import { describeRoute } from "hono-openapi"; import { User } from "@nestri/core/user/index"; import { Team } from "@nestri/core/team/index"; import { Examples } from "@nestri/core/examples"; +import { Polar } from "@nestri/core/polar/index"; import { Member } from "@nestri/core/member/index"; import { assertActor, withActor } from "@nestri/core/actor"; import { ErrorResponses, Result, validator } from "./common"; +import { Subscription } from "@nestri/core/subscription/index"; +import { PlanType } from "@nestri/core/subscription/subscription.sql"; export namespace TeamApi { export const route = new Hono() @@ -49,7 +52,12 @@ export namespace TeamApi { content: { "application/json": { schema: Result( - z.literal("ok") + z.object({ + checkoutUrl: z.string().openapi({ + description: "The checkout url to confirm subscription for this team", + example: "https://polar.sh/checkout/2903038439320298377" + }) + }) ) } }, @@ -63,17 +71,24 @@ export namespace TeamApi { }), validator( "json", - Team.create.schema.omit({ id: true }).openapi({ - description: "Details of the team to create", - //@ts-expect-error - example: { ...Examples.Team, id: undefined } - }) + Team.create.schema + .pick({ slug: true, name: true }) + .extend({ planType: z.enum(PlanType), successUrl: z.string().url("Success url must be a valid url") }) + .openapi({ + description: "Details of the team to create", + example: { + slug: Examples.Team.slug, + name: Examples.Team.name, + planType: Examples.Subscription.planType, + successUrl: "https://your-url.io/thanks" + }, + }) ), async (c) => { const body = c.req.valid("json") const actor = assertActor("user"); - const teamID = await Team.create(body); + const teamID = await Team.create({ name: body.name, slug: body.slug }); await withActor( { @@ -82,14 +97,28 @@ export namespace TeamApi { teamID, }, }, - () => - Member.create({ + async () => { + await Member.create({ first: true, email: actor.properties.email, - }), + }); + + await Subscription.create({ + planType: body.planType, + userID: actor.properties.userID, + // FIXME: Make this make sense + tokens: body.planType === "free" ? 100 : body.planType === "pro" ? 1000 : body.planType === "family" ? 10000 : 0, + }); + } ); - return c.json({ data: "ok" }) + const checkoutUrl = await Polar.createCheckout({ planType: body.planType, successUrl: body.successUrl, teamID }) + + return c.json({ + data: { + checkoutUrl, + } + }) } ) } \ No newline at end of file diff --git a/packages/functions/sst-env.d.ts b/packages/functions/sst-env.d.ts index ccf1988a..2c19b94f 100644 --- a/packages/functions/sst-env.d.ts +++ b/packages/functions/sst-env.d.ts @@ -57,10 +57,34 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "NestriFamilyMonthly": { + "type": "sst.sst.Secret" + "value": string + } + "NestriFamilyYearly": { + "type": "sst.sst.Secret" + "value": string + } + "NestriFreeMonthly": { + "type": "sst.sst.Secret" + "value": string + } + "NestriProMonthly": { + "type": "sst.sst.Secret" + "value": string + } + "NestriProYearly": { + "type": "sst.sst.Secret" + "value": string + } "PolarSecret": { "type": "sst.sst.Secret" "value": string } + "PolarWebhookSecret": { + "type": "sst.sst.Secret" + "value": string + } "Realtime": { "authorizer": string "endpoint": string diff --git a/packages/scripts/src/db-reset.sh b/packages/scripts/src/db-reset.sh old mode 100755 new mode 100644 diff --git a/packages/scripts/src/psql.sh b/packages/scripts/src/psql.sh old mode 100755 new mode 100644 diff --git a/packages/www/src/pages/new.tsx b/packages/www/src/pages/new.tsx index f3b0a37f..bf25a58f 100644 --- a/packages/www/src/pages/new.tsx +++ b/packages/www/src/pages/new.tsx @@ -8,8 +8,8 @@ import { useNavigate } from "@solidjs/router"; import { useOpenAuth } from "@openauthjs/solid"; import { utility } from "@nestri/www/ui/utility"; import { useAccount } from "../providers/account"; -import { Container, Screen as FullScreen } from "@nestri/www/ui/layout"; import { FormField, Input, Select } from "@nestri/www/ui/form"; +import { Container, Screen as FullScreen } from "@nestri/www/ui/layout"; import { createForm, getValue, setError, valiForm } from "@modular-forms/solid"; const nameRegex = /^[a-z0-9\-]+$/ @@ -32,8 +32,9 @@ const Hr = styled("hr", { }) const Plan = { - Pro: 'BYOG', - Basic: 'Hosted', + Free: 'free', + Pro: 'pro', + Family: 'family', } as const; const schema = v.object({ @@ -110,6 +111,13 @@ const UrlTitle = styled("span", { } }) +/** + * Renders a form for creating a new team with validated fields for team name, slug, and plan type. + * + * Submits the form data to the API to create the team, displays validation errors, and navigates to the new team's page upon success. + * + * @remark If the chosen team slug is already taken, an error message is shown for the slug field. + */ export function CreateTeamComponent() { const [form, { Form, Field }] = createForm({ validate: valiForm(schema), @@ -215,12 +223,14 @@ export function CreateTeamComponent() { required value={field.value} badges={[ - { label: "BYOG", color: "purple" }, - { label: "Hosted", color: "blue" }, + { label: "Free", color: "gray" }, + { label: "Pro", color: "blue" }, + { label: "Family", color: "purple" }, ]} options={[ - { label: "I'll be playing on my machine", value: 'BYOG' }, - { label: "I'll be playing on the cloud", value: 'Hosted' }, + { label: "I'll be playing by myself", value: 'free' }, + { label: "I'll be playing with 3 friends", value: 'pro' }, + { label: "I'll be playing with 5 family members", value: 'family' }, ]} /> diff --git a/packages/www/src/pages/team/header.tsx b/packages/www/src/pages/team/header.tsx index 8c0e5b64..3633592f 100644 --- a/packages/www/src/pages/team/header.tsx +++ b/packages/www/src/pages/team/header.tsx @@ -201,15 +201,12 @@ const Nav = styled("nav", { }) /** - * Renders the application's header, featuring navigation, branding, and team details. + * Displays the application's fixed top navigation bar with branding, team information, and navigation links. * - * This component displays a navigation bar that includes the logo, team avatar, team name, a badge - * reflecting the team's plan type, and navigation links. It adjusts its styling based on the scroll - * position by toggling visual effects on the navigation wrapper. A scroll event listener is added - * on mount to update the header's appearance when the user scrolls and is removed on unmount. + * The header includes the app logo, team avatar and name, a badge indicating the team's plan type, and navigation links related to the team. The header's appearance updates dynamically based on the user's scroll position. * - * @param props.children - Optional child elements rendered below the header component. - * @returns The header component element. + * @param props.children - Optional elements rendered below the header. + * @returns The rendered header component. */ export function Header(props: ParentProps) { // const team = useContext(TeamContext) @@ -218,7 +215,7 @@ export function Header(props: ParentProps) { id: "tea_01JPACSPYWTTJ66F32X3AWWFWE", slug: "wanjohiryan", name: "Wanjohi", - planType: "BYOG" + planType: "Pro" }) createEffect(() => { @@ -231,8 +228,9 @@ export function Header(props: ParentProps) { }); }) - + // const account = useAccount() + return ( @@ -294,14 +292,14 @@ export function Header(props: ParentProps) { /> {team!().name} - + - BYOG + Family - + - Hosted + Pro diff --git a/packages/www/src/pages/team/index.tsx b/packages/www/src/pages/team/index.tsx index d2848806..d14e3cff 100644 --- a/packages/www/src/pages/team/index.tsx +++ b/packages/www/src/pages/team/index.tsx @@ -44,7 +44,7 @@ export const TeamRoute = ( return ( - TODO: Add a public page for (other) teams + {/* TODO: Add a public page for (other) teams */} diff --git a/packages/www/src/ui/form.tsx b/packages/www/src/ui/form.tsx index cda55999..6e89ea66 100644 --- a/packages/www/src/ui/form.tsx +++ b/packages/www/src/ui/form.tsx @@ -157,6 +157,7 @@ export const InputRadio = styled("input", { const Label = styled("p", { base: { fontWeight: 500, + textAlign: "left", letterSpacing: -0.1, fontSize: theme.font.size.mono_sm, textTransform: "capitalize", @@ -213,6 +214,7 @@ const Hint = styled("p", { fontSize: theme.font.size.xs, lineHeight: theme.font.lineHeight, color: theme.color.gray.d800, + textAlign: "left" }, variants: { color: { diff --git a/sst-env.d.ts b/sst-env.d.ts index ccf1988a..2c19b94f 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -57,10 +57,34 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "NestriFamilyMonthly": { + "type": "sst.sst.Secret" + "value": string + } + "NestriFamilyYearly": { + "type": "sst.sst.Secret" + "value": string + } + "NestriFreeMonthly": { + "type": "sst.sst.Secret" + "value": string + } + "NestriProMonthly": { + "type": "sst.sst.Secret" + "value": string + } + "NestriProYearly": { + "type": "sst.sst.Secret" + "value": string + } "PolarSecret": { "type": "sst.sst.Secret" "value": string } + "PolarWebhookSecret": { + "type": "sst.sst.Secret" + "value": string + } "Realtime": { "authorizer": string "endpoint": string