feat(api): Add payments with Polar.sh (#264)

## Description
<!-- Briefly describe the purpose and scope of your changes -->


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Introduced a new subscription API endpoint for managing subscriptions
and products.
- Enhanced subscription management with new entities and
functionalities.
- Added functionality to retrieve current timestamps in both local and
UTC formats.
- Added Polar.sh integration with customer portal and checkout session
creation APIs.

- **Refactor**
- Redesigned team details to now present members and subscription
information instead of a plan type.
  - Enhanced member management by incorporating role assignments.
- Streamlined user data handling and removed legacy subscription event
logic.
  - Simplified error handling in actor functions for better clarity.
  - Updated plan types and UI labels to reflect new subscription tiers.
  - Improved database indexing for Steam user data.

- **Chores**
- Updated the database schema with new tables and fields to support
subscription, team, and member enhancements.
  - Extended identifier prefixes to broaden system integration.
- Added new secrets related to pricing plans in infrastructure
configuration.
  - Configured API and auth routing with new domain and routing rules.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Wanjohi
2025-04-18 14:24:19 +03:00
committed by GitHub
parent 76d27e4708
commit 47e61599bb
40 changed files with 3304 additions and 425 deletions

View File

@@ -6,15 +6,21 @@ import { cluster } from "./cluster";
import { postgres } from "./postgres"; import { postgres } from "./postgres";
export const api = new sst.aws.Service("Api", { export const api = new sst.aws.Service("Api", {
cluster,
cpu: $app.stage === "production" ? "2 vCPU" : undefined, cpu: $app.stage === "production" ? "2 vCPU" : undefined,
memory: $app.stage === "production" ? "4 GB" : undefined, memory: $app.stage === "production" ? "4 GB" : undefined,
cluster,
command: ["bun", "run", "./src/api/index.ts"], command: ["bun", "run", "./src/api/index.ts"],
link: [ link: [
bus, bus,
auth, auth,
postgres, postgres,
secret.PolarSecret, secret.PolarSecret,
secret.PolarWebhookSecret,
secret.NestriFamilyMonthly,
secret.NestriFamilyYearly,
secret.NestriFreeMonthly,
secret.NestriProMonthly,
secret.NestriProYearly,
], ],
image: { image: {
dockerfile: "packages/functions/Containerfile", dockerfile: "packages/functions/Containerfile",
@@ -23,16 +29,11 @@ export const api = new sst.aws.Service("Api", {
NO_COLOR: "1", NO_COLOR: "1",
}, },
loadBalancer: { loadBalancer: {
domain: "api." + domain,
rules: [ rules: [
{ {
listen: "80/http", listen: "80/http",
forward: "3001/http", forward: "3001/http",
}, },
{
listen: "443/https",
forward: "3001/http",
},
], ],
}, },
dev: { dev: {
@@ -48,3 +49,15 @@ export const api = new sst.aws.Service("Api", {
} }
: undefined, : undefined,
}); });
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(),
},
})

View File

@@ -1,26 +1,14 @@
import { bus } from "./bus"; import { bus } from "./bus";
import { domain } from "./dns"; import { domain } from "./dns";
// import { email } from "./email";
import { secret } from "./secret"; import { secret } from "./secret";
import { postgres } from "./postgres";
import { cluster } from "./cluster"; import { cluster } from "./cluster";
// sst.Linkable.wrap(random.RandomString, (resource) => ({ import { postgres } from "./postgres";
// properties: {
// value: resource.result,
// },
// }));
// export const authFingerprintKey = new random.RandomString(
// "AuthFingerprintKey",
// {
// length: 32,
// },
// );
//FIXME: Use a shared /tmp folder
export const auth = new sst.aws.Service("Auth", { export const auth = new sst.aws.Service("Auth", {
cluster,
cpu: $app.stage === "production" ? "1 vCPU" : undefined, cpu: $app.stage === "production" ? "1 vCPU" : undefined,
memory: $app.stage === "production" ? "2 GB" : undefined, memory: $app.stage === "production" ? "2 GB" : undefined,
cluster,
command: ["bun", "run", "./src/auth.ts"], command: ["bun", "run", "./src/auth.ts"],
link: [ link: [
bus, bus,
@@ -38,18 +26,12 @@ export const auth = new sst.aws.Service("Auth", {
NO_COLOR: "1", NO_COLOR: "1",
STORAGE: $dev ? "/tmp/persist.json" : "/mnt/efs/persist.json" STORAGE: $dev ? "/tmp/persist.json" : "/mnt/efs/persist.json"
}, },
//TODO: Use API gateway instead, because of the API headers
loadBalancer: { loadBalancer: {
domain: "auth." + domain,
rules: [ rules: [
{ {
listen: "80/http", listen: "80/http",
forward: "3002/http", forward: "3002/http",
}, },
{
listen: "443/https",
forward: "3002/http",
},
], ],
}, },
permissions: [ permissions: [
@@ -71,3 +53,14 @@ export const auth = new sst.aws.Service("Auth", {
} }
: undefined, : undefined,
}); });
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(),
},
})

View File

@@ -1,11 +1,17 @@
export const secret = { export const secret = {
// InstantAppId: new sst.Secret("InstantAppId"),
PolarSecret: new sst.Secret("PolarSecret", process.env.POLAR_API_KEY), PolarSecret: new sst.Secret("PolarSecret", process.env.POLAR_API_KEY),
GithubClientID: new sst.Secret("GithubClientID"), GithubClientID: new sst.Secret("GithubClientID"),
DiscordClientID: new sst.Secret("DiscordClientID"), DiscordClientID: new sst.Secret("DiscordClientID"),
PolarWebhookSecret: new sst.Secret("PolarWebhookSecret"),
GithubClientSecret: new sst.Secret("GithubClientSecret"), GithubClientSecret: new sst.Secret("GithubClientSecret"),
// InstantAdminToken: new sst.Secret("InstantAdminToken"),
DiscordClientSecret: new sst.Secret("DiscordClientSecret"), 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); export const allSecrets = Object.values(secret);

View File

@@ -0,0 +1,2 @@
ALTER TABLE "member" ADD COLUMN "role" text NOT NULL;--> statement-breakpoint
ALTER TABLE "team" DROP COLUMN "plan_type";

View File

@@ -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;

View File

@@ -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");

View File

@@ -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");

View File

@@ -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": {}
}
}

View File

@@ -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": {}
}
}

View File

@@ -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": {}
}
}

View File

@@ -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": {}
}
}

View File

@@ -43,6 +43,34 @@
"when": 1744614896792, "when": 1744614896792,
"tag": "0005_aspiring_stature", "tag": "0005_aspiring_stature",
"breakpoints": true "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
} }
] ]
} }

View File

@@ -109,43 +109,34 @@ export function assertActor<T extends Actor["type"]>(type: T) {
return actor as Extract<Actor, { type: T }>; return actor as Extract<Actor, { type: T }>;
} }
/**
* 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() { export function useTeam() {
const actor = useActor(); const actor = useActor();
if ("teamID" in actor.properties) return actor.properties.teamID; if ("teamID" in actor.properties) return actor.properties.teamID;
throw new Error(`Expected actor to have teamID`); throw new VisibleError(
} "authentication",
ErrorCodes.Authentication.UNAUTHORIZED,
export function useMachine() { `Expected actor to have teamID`
const actor = useActor(); );
if ("machineID" in actor.properties) return actor.properties.fingerprint;
throw new Error(`Expected actor to have fingerprint`);
} }
/** /**
* 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. * @returns The fingerprint of the current machine actor.
* If the flags are missing, it throws a {@link VisibleError} with the code {@link ErrorCodes.Validation.MISSING_REQUIRED_FIELD} * @throws {VisibleError} If the current actor does not have a machine identity.
* 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.
*/ */
export async function assertUserFlag(flag: keyof UserFlags) { export function useMachine() {
return useTransaction((tx) => const actor = useActor();
tx if ("machineID" in actor.properties) return actor.properties.fingerprint;
.select({ flags: userTable.flags })
.from(userTable)
.where(eq(userTable.id, useUserID()))
.then((rows) => {
const flags = rows[0]?.flags;
if (!flags)
throw new VisibleError( throw new VisibleError(
"not_found", "authentication",
ErrorCodes.Validation.MISSING_REQUIRED_FIELD, ErrorCodes.Authentication.UNAUTHORIZED,
"Actor does not have " + flag + " flag", `Expected actor to have fingerprint`
);
}),
); );
} }

View File

@@ -1,7 +1,11 @@
import { sql } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import "zod-openapi/extend"; import "zod-openapi/extend";
export namespace Common { export namespace Common {
export const IdDescription = `Unique object identifier. export const IdDescription = `Unique object identifier.
The format and length of IDs may change over time.`; The format and length of IDs may change over time.`;
export const now = () => sql`now()`;
export const utc = () => sql`now() at time zone 'utc'`;
} }

View File

@@ -1,4 +1,5 @@
import { char, timestamp as rawTs } from "drizzle-orm/pg-core"; 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 }); export const ulid = (name: string) => char(name, { length: 26 + 4 });

View File

@@ -34,23 +34,38 @@ export namespace Examples {
steamAccounts: [Steam] steamAccounts: [Steam]
}; };
export const Team = { export const Product = {
id: Id("team"), id: Id("product"),
name: "John Does' Team", name: "RTX 4090",
slug: "john_doe", description: "Ideal for dedicated gamers who crave more flexibility and social gaming experiences.",
planType: "BYOG" as const 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 = { export const Member = {
id: Id("member"), id: Id("member"),
email: "john@example.com", email: "john@example.com",
teamID: Id("team"), teamID: Id("team"),
role: "admin" as const,
timeSeen: new Date("2025-02-23T13:39:52.249Z"), timeSeen: new Date("2025-02-23T13:39:52.249Z"),
} }
export const Polar = { export const Team = {
teamID: Id("team"), id: Id("team"),
timeSeen: new Date("2025-02-23T13:39:52.249Z"), name: "John Does' Team",
slug: "john_doe",
subscriptions: [Subscription],
members: [Member]
} }
export const Machine = { export const Machine = {

View File

@@ -6,7 +6,7 @@ import { Common } from "../common";
import { createID, fn } from "../utils"; import { createID, fn } from "../utils";
import { createEvent } from "../event"; import { createEvent } from "../event";
import { Examples } from "../examples"; import { Examples } from "../examples";
import { memberTable } from "./member.sql"; import { memberTable, role } from "./member.sql";
import { and, eq, sql, asc, isNull } from "../drizzle"; import { and, eq, sql, asc, isNull } from "../drizzle";
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction"; import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
@@ -17,7 +17,7 @@ export namespace Member {
description: Common.IdDescription, description: Common.IdDescription,
example: Examples.Member.id, 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", description: "The last time this team member was active",
example: Examples.Member.timeSeen example: Examples.Member.timeSeen
}), }),
@@ -25,6 +25,10 @@ export namespace Member {
description: "The unique id of the team this member is on", description: "The unique id of the team this member is on",
example: Examples.Member.teamID example: Examples.Member.teamID
}), }),
role: z.enum(role).openapi({
description: "The role of this team member",
example: Examples.Member.role
}),
email: z.string().openapi({ email: z.string().openapi({
description: "The email of this team member", description: "The email of this team member",
example: Examples.Member.email example: Examples.Member.email
@@ -68,6 +72,7 @@ export namespace Member {
id, id,
teamID: useTeam(), teamID: useTeam(),
email: input.email, email: input.email,
role: input.first ? "owner" : "member",
timeSeen: input.first ? sql`now()` : null, 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( export function serialize(
input: typeof memberTable.$inferSelect, input: typeof memberTable.$inferSelect,
): z.infer<typeof Info> { ): z.infer<typeof Info> {
return { return {
id: input.id, id: input.id,
role: input.role,
email: input.email, email: input.email,
teamID: input.teamID, teamID: input.teamID,
timeSeen: input.timeSeen timeSeen: input.timeSeen

View File

@@ -1,12 +1,15 @@
import { teamIndexes } from "../team/team.sql"; import { teamIndexes } from "../team/team.sql";
import { timestamps, utc, teamID } from "../drizzle/types"; 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( export const memberTable = pgTable(
"member", "member",
{ {
...teamID, ...teamID,
...timestamps, ...timestamps,
role: text("role", { enum: role }).notNull(),
timeSeen: utc("time_seen"), timeSeen: utc("time_seen"),
email: varchar("email", { length: 255 }).notNull(), email: varchar("email", { length: 255 }).notNull(),
}, },

View File

@@ -1,69 +1,16 @@
import { z } from "zod"; import { z } from "zod";
import { fn } from "../utils"; import { fn } from "../utils";
import { Resource } from "sst"; import { Resource } from "sst";
import { eq, and } from "../drizzle"; import { useTeam, useUserID } from "../actor";
import { useTeam } from "../actor";
import { createEvent } from "../event";
// import { polarTable, Standing } from "./polar.sql.ts.test";
import { Polar as PolarSdk } from "@polar-sh/sdk"; 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 polar = new PolarSdk({ accessToken: Resource.PolarSecret.value, server: Resource.App.stage !== "production" ? "sandbox" : "production" });
const planType = z.enum(PlanType)
export namespace Polar { export namespace Polar {
export const client = 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<typeof Info>;
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) => { export const fromUserEmail = fn(z.string().min(1), async (email) => {
try { try {
const customers = await client.customers.list({ email }) const customers = await client.customers.list({ email })
@@ -81,89 +28,69 @@ export namespace Polar {
} }
}) })
// export const setCustomerID = fn(Info.shape.customerID, async (customerID) => const getProductIDs = (plan: z.infer<typeof planType>) => {
// useTransaction(async (tx) => switch (plan) {
// tx case "free":
// .insert(polarTable) return [Resource.NestriFreeMonthly.value]
// .values({ case "pro":
// teamID: useTeam(), return [Resource.NestriProYearly.value, Resource.NestriProMonthly.value]
// customerID, case "family":
// standing: "new", return [Resource.NestriFamilyYearly.value, Resource.NestriFamilyMonthly.value]
// }) default:
// .execute(), return [Resource.NestriFreeMonthly.value]
// ), }
// ); }
// export const setSubscription = fn( export const createPortal = fn(
// Info.pick({ z.string(),
// subscriptionID: true, async (customerId) => {
// subscriptionItemID: true, const session = await client.customerSessions.create({
// }), customerId
// (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 removeSubscription = fn( return session.customerPortalUrl
// z.string().min(1), }
// (stripeSubscriptionID) => )
// useTransaction((tx) =>
// tx
// .update(polarTable)
// .set({
// subscriptionItemID: null,
// subscriptionID: null,
// })
// .where(and(eq(polarTable.subscriptionID, stripeSubscriptionID)))
// .execute(),
// ),
// );
// export const setStanding = fn( //TODO: Implement this
// Info.pick({ export const handleWebhook = async(payload: ReturnType<typeof validateEvent>) => {
// subscriptionID: true, switch (payload.type) {
// standing: true, case "subscription.created":
// }), const teamID = payload.data.metadata.teamID
// (input) => }
// useTransaction((tx) => }
// tx
// .update(polarTable)
// .set({ standing: input.standing })
// .where(and(eq(polarTable.subscriptionID, input.subscriptionID!)))
// .execute(),
// ),
// );
// export const fromCustomerID = fn(Info.shape.customerID, (customerID) => export const createCheckout = fn(
// useTransaction((tx) => z
// tx .object({
// .select() planType: z.enum(PlanType),
// .from(polarTable) customerEmail: z.string(),
// .where(and(eq(polarTable.customerID, customerID))) successUrl: z.string(),
// .execute() customerID: z.string(),
// .then((rows) => rows.map(serialize).at(0)), allowDiscountCodes: z.boolean(),
// ), teamID: z.string()
// ); })
.partial({
customerEmail: true,
allowDiscountCodes: true,
customerID: true,
teamID: true
}),
async (input) => {
const productIDs = getProductIDs(input.planType)
// function serialize( const checkoutUrl =
// input: typeof polarTable.$inferSelect, await client.checkouts.create({
// ): z.infer<typeof Info> { products: productIDs,
// return { customerEmail: input.customerEmail ?? useUserID(),
// teamID: input.teamID, successUrl: `${input.successUrl}?checkout={CHECKOUT_ID}`,
// customerID: input.customerID, allowDiscountCodes: input.allowDiscountCodes ?? false,
// subscriptionID: input.subscriptionID, customerId: input.customerID,
// subscriptionItemID: input.subscriptionItemID, customerMetadata: {
// standing: input.standing, teamID: input.teamID ?? useTeam()
// }; }
// } })
return checkoutUrl.url
})
} }

View File

@@ -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),
]
)

View File

@@ -1,24 +1,7 @@
import { z } from "zod"; 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"; import { userTable } from "../user/user.sql";
import { id, timestamps, ulid, utc } from "../drizzle/types";
import { index, pgTable, integer, uniqueIndex, varchar, text, json } from "drizzle-orm/pg-core";
// 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;
export const LastGame = z.object({ export const LastGame = z.object({
gameID: z.number(), gameID: z.number(),
@@ -54,5 +37,9 @@ export const steamTable = pgTable(
steamEmail: varchar("steam_email", { length: 255 }).notNull(), steamEmail: varchar("steam_email", { length: 255 }).notNull(),
personaName: varchar("persona_name", { length: 255 }).notNull(), personaName: varchar("persona_name", { length: 255 }).notNull(),
limitation: json("limitation").$type<AccountLimitation>().notNull(), limitation: json("limitation").$type<AccountLimitation>().notNull(),
} },
(table) => [
uniqueIndex("steam_id").on(table.steamID),
index("steam_user_id").on(table.userID),
],
); );

View File

@@ -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<typeof Info>;
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<typeof Info> {
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,
};
}
}

View File

@@ -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]
}),
]
)

View File

@@ -1,16 +1,18 @@
import { z } from "zod"; import { z } from "zod";
import { Resource } from "sst";
import { bus } from "sst/aws/bus";
import { Common } from "../common"; import { Common } from "../common";
import { Member } from "../member";
import { teamTable } from "./team.sql";
import { Examples } from "../examples"; import { Examples } from "../examples";
import { assertActor } from "../actor";
import { createEvent } from "../event"; import { createEvent } from "../event";
import { createID, fn } from "../utils"; import { createID, fn } from "../utils";
import { Subscription } from "../subscription";
import { and, eq, sql, isNull } from "../drizzle"; 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 { memberTable } from "../member/member.sql";
import { ErrorCodes, VisibleError } from "../error"; 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 namespace Team {
export const Info = z export const Info = z
@@ -19,6 +21,7 @@ export namespace Team {
description: Common.IdDescription, description: Common.IdDescription,
example: Examples.Team.id, 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({ slug: z.string().regex(/^[a-z0-9\-]+$/, "Use a URL friendly name.").openapi({
description: "The unique and url-friendly slug of this team", description: "The unique and url-friendly slug of this team",
example: Examples.Team.slug example: Examples.Team.slug
@@ -27,10 +30,14 @@ export namespace Team {
description: "The name of this team", description: "The name of this team",
example: Examples.Team.name example: Examples.Team.name
}), }),
planType: z.enum(PlanType).openapi({ members: Member.Info.array().openapi({
description: "The type of Plan this team is subscribed to", description: "The members of this team",
example: Examples.Team.planType example: Examples.Team.members
}) }),
subscriptions: Subscription.Info.array().openapi({
description: "The subscriptions of this team",
example: Examples.Team.subscriptions
}),
}) })
.openapi({ .openapi({
ref: "Team", ref: "Team",
@@ -60,16 +67,14 @@ export namespace Team {
} }
export const create = fn( 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, id: true,
}), (input) => }), (input) =>
createTransaction(async (tx) => { createTransaction(async (tx) => {
const id = input.id ?? createID("team"); const id = input.id ?? createID("team");
const result = await tx.insert(teamTable).values({ const result = await tx.insert(teamTable).values({
id, id,
//Remove spaces and make sure it is lowercase (this is just to make sure the frontend did this) slug: input.slug,
slug: input.slug, //.toLowerCase().replace(/[\s]/g, ''),
planType: input.planType,
name: input.name name: input.name
}) })
.onConflictDoNothing({ target: teamTable.slug }) .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) => export const remove = fn(Info.shape.id, (input) =>
useTransaction(async (tx) => { useTransaction(async (tx) => {
const account = assertActor("user"); const account = assertActor("user");
@@ -106,48 +112,107 @@ export namespace Team {
}), }),
); );
export const list = fn(z.void(), () => export const list = fn(z.void(), () => {
useTransaction((tx) => const actor = assertActor("user");
return useTransaction(async (tx) =>
tx tx
.select() .select()
.from(teamTable) .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() .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) => export const fromSlug = fn(z.string().min(1), async (slug) =>
useTransaction(async (tx) => { useTransaction(async (tx) =>
return tx tx
.select() .select()
.from(teamTable) .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() .execute()
.then((rows) => rows.map(serialize).at(0)) .then((rows) => serialize(rows).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))
}),
); );
/**
* 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( export function serialize(
input: typeof teamTable.$inferSelect, input: { team: typeof teamTable.$inferSelect, subscription: typeof subscriptionTable.$inferInsert | null, member: typeof memberTable.$inferInsert | null }[],
): z.infer<typeof Info> { ): z.infer<typeof Info>[] {
return { console.log("serialize", input)
id: input.id, return pipe(
name: input.name, input,
slug: input.slug, groupBy((row) => row.team.id),
planType: input.planType, 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,
}))
})),
);
} }
} }

View File

@@ -1,15 +1,11 @@
import { } from "drizzle-orm/postgres-js";
import { timestamps, id } from "../drizzle/types"; import { timestamps, id } from "../drizzle/types";
import { import {
varchar, varchar,
pgTable, pgTable,
primaryKey, primaryKey,
uniqueIndex, uniqueIndex,
text
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
export const PlanType = ["Hosted", "BYOG"] as const;
export const teamTable = pgTable( export const teamTable = pgTable(
"team", "team",
{ {
@@ -17,7 +13,6 @@ export const teamTable = pgTable(
...timestamps, ...timestamps,
name: varchar("name", { length: 255 }).notNull(), name: varchar("name", { length: 255 }).notNull(),
slug: varchar("slug", { length: 255 }).notNull(), slug: varchar("slug", { length: 255 }).notNull(),
planType: text("plan_type", { enum: PlanType }).notNull()
}, },
(table) => [ (table) => [
uniqueIndex("slug").on(table.slug) uniqueIndex("slug").on(table.slug)

View File

@@ -1,21 +1,22 @@
import { z } from "zod"; import { z } from "zod";
import { Team } from "../team"; import { Team } from "../team";
import { bus } from "sst/aws/bus"; import { bus } from "sst/aws/bus";
import { Steam } from "../steam";
import { Common } from "../common"; import { Common } from "../common";
import { Polar } from "../polar/index"; import { Polar } from "../polar/index";
import { createID, fn } from "../utils"; import { createID, fn } from "../utils";
import { userTable } from "./user.sql"; import { userTable } from "./user.sql";
import { createEvent } from "../event"; import { createEvent } from "../event";
import { pipe, groupBy, values, map } from "remeda";
import { Examples } from "../examples"; import { Examples } from "../examples";
import { Resource } from "sst/resource"; import { Resource } from "sst/resource";
import { teamTable } from "../team/team.sql"; import { teamTable } from "../team/team.sql";
import { steamTable } from "../steam/steam.sql"; import { steamTable } from "../steam/steam.sql";
import { assertActor, withActor } from "../actor"; import { assertActor, withActor } from "../actor";
import { memberTable } from "../member/member.sql"; 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 { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
import { Steam } from "../steam";
export namespace User { export namespace User {
@@ -154,91 +155,27 @@ export namespace User {
}) })
export const fromEmail = fn(z.string(), async (email) => export const fromEmail = fn(z.string(), async (email) =>
useTransaction(async (tx) => { useTransaction(async (tx) =>
const rows = await tx tx
.select() .select()
.from(userTable) .from(userTable)
.leftJoin(steamTable, eq(userTable.id, steamTable.userID)) .leftJoin(steamTable, eq(userTable.id, steamTable.userID))
.where(and(eq(userTable.email, email), isNull(userTable.timeDeleted))) .where(and(eq(userTable.email, email), isNull(userTable.timeDeleted)))
.orderBy(asc(userTable.timeCreated)) .orderBy(asc(userTable.timeCreated))
.then((rows => serialize(rows).at(0)))
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] export const fromID = fn(z.string(), (id) =>
}), useTransaction(async (tx) =>
) tx
export const fromID = fn(z.string(), async (id) =>
useTransaction(async (tx) => {
const rows = await tx
.select() .select()
.from(userTable) .from(userTable)
.leftJoin(steamTable, eq(userTable.id, steamTable.userID)) .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)) .orderBy(asc(userTable.timeCreated))
.then((rows) => serialize(rows).at(0))
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]
}),
) )
export const remove = fn(Info.shape.id, (id) => 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<typeof Info>[] {
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() { export function teams() {
const actor = assertActor("user"); const actor = assertActor("user");
return useTransaction((tx) => return useTransaction(async (tx) =>
tx tx
.select(getTableColumns(teamTable)) .select()
.from(teamTable) .from(teamTable)
.leftJoin(subscriptionTable, eq(subscriptionTable.teamID, teamTable.id))
.innerJoin(memberTable, eq(memberTable.teamID, teamTable.id)) .innerJoin(memberTable, eq(memberTable.teamID, teamTable.id))
.where( .where(
and( and(
@@ -269,7 +248,7 @@ export namespace User {
), ),
) )
.execute() .execute()
.then((rows) => rows.map(Team.serialize)) .then((rows) => Team.serialize(rows))
); )
} }
} }

View File

@@ -2,11 +2,14 @@ import { ulid } from "ulid";
export const prefixes = { export const prefixes = {
user: "usr", user: "usr",
team: "tea", team: "tem",
task: "tsk", task: "tsk",
machine: "mch", machine: "mch",
member: "mbr", member: "mbr",
steam: "stm", steam: "stm",
subscription: "sub",
invite: "inv",
product: "prd",
} as const; } as const;
/** /**

View File

@@ -43,11 +43,6 @@ export const auth: MiddlewareHandler = async (c, next) => {
); );
} }
if (result.subject.type === "machine") {
console.log("machine detected")
return withActor(result.subject, next);
}
if (result.subject.type === "user") { if (result.subject.type === "user") {
const teamID = c.req.header("x-nestri-team"); const teamID = c.req.header("x-nestri-team");
if (!teamID) return withActor(result.subject, next); if (!teamID) return withActor(result.subject, next);
@@ -58,12 +53,11 @@ export const auth: MiddlewareHandler = async (c, next) => {
teamID, teamID,
}, },
}, },
async () => { async () =>
return withActor( withActor(
result.subject, result.subject,
next, next,
); )
},
); );
} }
}; };

View File

@@ -3,7 +3,7 @@ import { Hono } from "hono";
import { auth } from "./auth"; import { auth } from "./auth";
import { cors } from "hono/cors"; import { cors } from "hono/cors";
import { TeamApi } from "./team"; import { TeamApi } from "./team";
import { SteamApi } from "./steam"; import { PolarApi } from "./polar";
import { logger } from "hono/logger"; import { logger } from "hono/logger";
import { Realtime } from "./realtime"; import { Realtime } from "./realtime";
import { AccountApi } from "./account"; import { AccountApi } from "./account";
@@ -27,7 +27,7 @@ const routes = app
.get("/", (c) => c.text("Hello World!")) .get("/", (c) => c.text("Hello World!"))
.route("/realtime", Realtime.route) .route("/realtime", Realtime.route)
.route("/team", TeamApi.route) .route("/team", TeamApi.route)
.route("/steam", SteamApi.route) .route("/polar", PolarApi.route)
.route("/account", AccountApi.route) .route("/account", AccountApi.route)
.route("/machine", MachineApi.route) .route("/machine", MachineApi.route)
.onError((error, c) => { .onError((error, c) => {

View File

@@ -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<typeof validateEvent>;
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 });
}
)
}

View File

@@ -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
})
}
)
}

View File

@@ -5,9 +5,12 @@ import { describeRoute } from "hono-openapi";
import { User } from "@nestri/core/user/index"; import { User } from "@nestri/core/user/index";
import { Team } from "@nestri/core/team/index"; import { Team } from "@nestri/core/team/index";
import { Examples } from "@nestri/core/examples"; import { Examples } from "@nestri/core/examples";
import { Polar } from "@nestri/core/polar/index";
import { Member } from "@nestri/core/member/index"; import { Member } from "@nestri/core/member/index";
import { assertActor, withActor } from "@nestri/core/actor"; import { assertActor, withActor } from "@nestri/core/actor";
import { ErrorResponses, Result, validator } from "./common"; 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 namespace TeamApi {
export const route = new Hono() export const route = new Hono()
@@ -49,7 +52,12 @@ export namespace TeamApi {
content: { content: {
"application/json": { "application/json": {
schema: Result( 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( validator(
"json", "json",
Team.create.schema.omit({ id: true }).openapi({ 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", description: "Details of the team to create",
//@ts-expect-error example: {
example: { ...Examples.Team, id: undefined } slug: Examples.Team.slug,
name: Examples.Team.name,
planType: Examples.Subscription.planType,
successUrl: "https://your-url.io/thanks"
},
}) })
), ),
async (c) => { async (c) => {
const body = c.req.valid("json") const body = c.req.valid("json")
const actor = assertActor("user"); const actor = assertActor("user");
const teamID = await Team.create(body); const teamID = await Team.create({ name: body.name, slug: body.slug });
await withActor( await withActor(
{ {
@@ -82,14 +97,28 @@ export namespace TeamApi {
teamID, teamID,
}, },
}, },
() => async () => {
Member.create({ await Member.create({
first: true, first: true,
email: actor.properties.email, 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,
}
})
} }
) )
} }

View File

@@ -57,10 +57,34 @@ declare module "sst" {
"type": "sst.sst.Secret" "type": "sst.sst.Secret"
"value": string "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": { "PolarSecret": {
"type": "sst.sst.Secret" "type": "sst.sst.Secret"
"value": string "value": string
} }
"PolarWebhookSecret": {
"type": "sst.sst.Secret"
"value": string
}
"Realtime": { "Realtime": {
"authorizer": string "authorizer": string
"endpoint": string "endpoint": string

0
packages/scripts/src/db-reset.sh Executable file → Normal file
View File

0
packages/scripts/src/psql.sh Executable file → Normal file
View File

View File

@@ -8,8 +8,8 @@ import { useNavigate } from "@solidjs/router";
import { useOpenAuth } from "@openauthjs/solid"; import { useOpenAuth } from "@openauthjs/solid";
import { utility } from "@nestri/www/ui/utility"; import { utility } from "@nestri/www/ui/utility";
import { useAccount } from "../providers/account"; 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 { 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"; import { createForm, getValue, setError, valiForm } from "@modular-forms/solid";
const nameRegex = /^[a-z0-9\-]+$/ const nameRegex = /^[a-z0-9\-]+$/
@@ -32,8 +32,9 @@ const Hr = styled("hr", {
}) })
const Plan = { const Plan = {
Pro: 'BYOG', Free: 'free',
Basic: 'Hosted', Pro: 'pro',
Family: 'family',
} as const; } as const;
const schema = v.object({ 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() { export function CreateTeamComponent() {
const [form, { Form, Field }] = createForm({ const [form, { Form, Field }] = createForm({
validate: valiForm(schema), validate: valiForm(schema),
@@ -215,12 +223,14 @@ export function CreateTeamComponent() {
required required
value={field.value} value={field.value}
badges={[ badges={[
{ label: "BYOG", color: "purple" }, { label: "Free", color: "gray" },
{ label: "Hosted", color: "blue" }, { label: "Pro", color: "blue" },
{ label: "Family", color: "purple" },
]} ]}
options={[ options={[
{ label: "I'll be playing on my machine", value: 'BYOG' }, { label: "I'll be playing by myself", value: 'free' },
{ label: "I'll be playing on the cloud", value: 'Hosted' }, { label: "I'll be playing with 3 friends", value: 'pro' },
{ label: "I'll be playing with 5 family members", value: 'family' },
]} ]}
/> />
</FormField> </FormField>

View File

@@ -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 * 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.
* 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.
* *
* @param props.children - Optional child elements rendered below the header component. * @param props.children - Optional elements rendered below the header.
* @returns The header component element. * @returns The rendered header component.
*/ */
export function Header(props: ParentProps) { export function Header(props: ParentProps) {
// const team = useContext(TeamContext) // const team = useContext(TeamContext)
@@ -218,7 +215,7 @@ export function Header(props: ParentProps) {
id: "tea_01JPACSPYWTTJ66F32X3AWWFWE", id: "tea_01JPACSPYWTTJ66F32X3AWWFWE",
slug: "wanjohiryan", slug: "wanjohiryan",
name: "Wanjohi", name: "Wanjohi",
planType: "BYOG" planType: "Pro"
}) })
createEffect(() => { createEffect(() => {
@@ -233,6 +230,7 @@ export function Header(props: ParentProps) {
}) })
// const account = useAccount() // const account = useAccount()
return ( return (
<PageWrapper> <PageWrapper>
<NavWrapper scrolled={hasScrolled()}> <NavWrapper scrolled={hasScrolled()}>
@@ -294,14 +292,14 @@ export function Header(props: ParentProps) {
/> />
<TeamLabel style={{ color: theme.color.d1000.gray }}>{team!().name}</TeamLabel> <TeamLabel style={{ color: theme.color.d1000.gray }}>{team!().name}</TeamLabel>
<Switch> <Switch>
<Match when={team!().planType === "BYOG"}> <Match when={team!().planType === "Family"}>
<Badge style={{ "background-color": theme.color.purple.d700 }}> <Badge style={{ "background-color": theme.color.purple.d700 }}>
<span style={{ "line-height": 0 }} >BYOG</span> <span style={{ "line-height": 0 }} >Family</span>
</Badge> </Badge>
</Match> </Match>
<Match when={team!().planType === "Hosted"}> <Match when={team!().planType === "Pro"}>
<Badge style={{ "background-color": theme.color.blue.d700 }}> <Badge style={{ "background-color": theme.color.blue.d700 }}>
<span style={{ "line-height": 0 }}>Hosted</span> <span style={{ "line-height": 0 }}>Pro</span>
</Badge> </Badge>
</Match> </Match>
</Switch> </Switch>

View File

@@ -44,7 +44,7 @@ export const TeamRoute = (
return ( return (
<Switch> <Switch>
<Match when={!team()}> <Match when={!team()}>
TODO: Add a public page for (other) teams {/* TODO: Add a public page for (other) teams */}
<NotAllowed header /> <NotAllowed header />
</Match> </Match>
<Match when={team()}> <Match when={team()}>

View File

@@ -157,6 +157,7 @@ export const InputRadio = styled("input", {
const Label = styled("p", { const Label = styled("p", {
base: { base: {
fontWeight: 500, fontWeight: 500,
textAlign: "left",
letterSpacing: -0.1, letterSpacing: -0.1,
fontSize: theme.font.size.mono_sm, fontSize: theme.font.size.mono_sm,
textTransform: "capitalize", textTransform: "capitalize",
@@ -213,6 +214,7 @@ const Hint = styled("p", {
fontSize: theme.font.size.xs, fontSize: theme.font.size.xs,
lineHeight: theme.font.lineHeight, lineHeight: theme.font.lineHeight,
color: theme.color.gray.d800, color: theme.color.gray.d800,
textAlign: "left"
}, },
variants: { variants: {
color: { color: {

24
sst-env.d.ts vendored
View File

@@ -57,10 +57,34 @@ declare module "sst" {
"type": "sst.sst.Secret" "type": "sst.sst.Secret"
"value": string "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": { "PolarSecret": {
"type": "sst.sst.Secret" "type": "sst.sst.Secret"
"value": string "value": string
} }
"PolarWebhookSecret": {
"type": "sst.sst.Secret"
"value": string
}
"Realtime": { "Realtime": {
"authorizer": string "authorizer": string
"endpoint": string "endpoint": string