diff --git a/package.json b/package.json index 407f82af..31c08650 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,5 @@ { "name": "nestri", - "private": true, - "scripts": { - "build": "turbo build", - "dev": "turbo dev", - "format": "prettier --write \"**/*.{ts,tsx,md}\"", - "lint": "turbo lint", - "sso": "aws sso login --sso-session=nestri --no-browser --use-device-code" - }, "devDependencies": { "@cloudflare/workers-types": "4.20240821.1", "@pulumi/pulumi": "^3.134.0", @@ -18,16 +10,21 @@ "engines": { "node": ">=18" }, - "packageManager": "bun@1.1.18", - "workspaces": [ - "apps/*", - "packages/*" - ], + "packageManager": "bun@1.2.4", + "private": true, + "scripts": { + "format": "prettier --write \"**/*.{ts,tsx,md}\"", + "sso": "aws sso login --sso-session=nestri --no-browser --use-device-code" + }, "trustedDependencies": [ "core-js-pure", "esbuild", "workerd" ], + "workspaces": [ + "apps/*", + "packages/*" + ], "dependencies": { "sst": "3.9.1" } diff --git a/packages/core/package.json b/packages/core/package.json index 67488454..5e7cd610 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -31,6 +31,7 @@ "@aws-sdk/client-sesv2": "^3.753.0", "@instantdb/admin": "^0.17.7", "@neondatabase/serverless": "^0.10.4", + "@openauthjs/openauth": "0.4.3", "@openauthjs/openevent": "^0.0.27", "@polar-sh/sdk": "^0.26.1", "drizzle-orm": "^0.39.3", diff --git a/packages/core/src/examples.ts b/packages/core/src/examples.ts index bb59bb93..793ac7f3 100644 --- a/packages/core/src/examples.ts +++ b/packages/core/src/examples.ts @@ -1,4 +1,3 @@ -import { teamID } from "./drizzle/types"; import { prefixes } from "./utils"; export module Examples { export const Id = (prefix: keyof typeof prefixes) => @@ -18,7 +17,7 @@ export module Examples { name: "John Does' Team", slug: "john_doe", } - + export const Member = { id: Id("member"), email: "john@example.com", @@ -26,4 +25,9 @@ export module Examples { timeSeen: new Date("2025-02-23T13:39:52.249Z"), } + export const Polar = { + teamID: Id("team"), + timeSeen: new Date("2025-02-23T13:39:52.249Z"), + } + } \ No newline at end of file diff --git a/packages/core/src/polar.ts b/packages/core/src/polar.ts deleted file mode 100644 index 660aff52..00000000 --- a/packages/core/src/polar.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Resource } from "sst"; -import { Polar as PolarSdk } from "@polar-sh/sdk"; - -const polar = new PolarSdk({ accessToken: Resource.PolarSecret.value, server: Resource.App.stage !== "production" ? "sandbox" : "production" }); - -export module Polar { - export const client = polar; -} \ No newline at end of file diff --git a/packages/core/src/polar/index.ts b/packages/core/src/polar/index.ts new file mode 100644 index 00000000..36c214a6 --- /dev/null +++ b/packages/core/src/polar/index.ts @@ -0,0 +1,169 @@ +import { z } from "zod"; +import { fn } from "../utils"; +import { Resource } from "sst"; +import { eq, and } from "../drizzle"; +import { useTeam } from "../actor"; +import { createEvent } from "../event"; +import { polarTable, Standing } from "./polar.sql"; +import { Polar as PolarSdk } from "@polar-sh/sdk"; +import { useTransaction } from "../drizzle/transaction"; + +const polar = new PolarSdk({ accessToken: Resource.PolarSecret.value, server: Resource.App.stage !== "production" ? "sandbox" : "production" }); + +export module Polar { + export const client = polar; + + export const Info = z.object({ + teamID: z.string(), + customerID: z.string(), + subscriptionID: z.string().nullable(), + subscriptionItemID: z.string().nullable(), + standing: z.enum(Standing), + }); + + export type Info = z.infer; + + export const Checkout = z.object({ + annual: z.boolean().optional(), + successUrl: z.string(), + cancelUrl: z.string(), + }); + + export const CheckoutSession = z.object({ + url: z.string().nullable(), + }); + + export const CustomerSubscriptionEventType = [ + "created", + "updated", + "deleted", + ] as const; + + export const Events = { + CustomerSubscriptionEvent: createEvent( + "polar.customer-subscription-event", + z.object({ + type: z.enum(CustomerSubscriptionEventType), + status: z.string(), + teamID: z.string().min(1), + customerID: z.string().min(1), + subscriptionID: z.string().min(1), + subscriptionItemID: z.string().min(1), + }), + ), + }; + + export function get() { + return useTransaction(async (tx) => + tx + .select() + .from(polarTable) + .where(eq(polarTable.teamID, useTeam())) + .execute() + .then((rows) => rows.map(serialize).at(0)), + ); + } + + export const fromUserEmail = fn(z.string().min(1), async (email) => { + try { + const customers = await client.customers.list({ email }) + + if (customers.result.items.length === 0) { + return await client.customers.create({ email }) + } else { + return customers.result.items[0] + } + + } catch (err) { + //FIXME: This is the issue [Polar.sh/#5147](https://github.com/polarsource/polar/issues/5147) + // console.log("error", err) + return undefined + } + }) + + export const setCustomerID = fn(Info.shape.customerID, async (customerID) => + useTransaction(async (tx) => + tx + .insert(polarTable) + .values({ + teamID: useTeam(), + customerID, + standing: "new", + }) + .execute(), + ), + ); + + export const setSubscription = fn( + Info.pick({ + subscriptionID: true, + subscriptionItemID: true, + }), + (input) => + useTransaction(async (tx) => + tx + .update(polarTable) + .set({ + subscriptionID: input.subscriptionID, + subscriptionItemID: input.subscriptionItemID, + }) + .where(eq(polarTable.teamID, useTeam())) + .returning() + .execute() + .then((rows) => rows.map(serialize).at(0)), + ), + ); + + export const removeSubscription = fn( + 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( + Info.pick({ + subscriptionID: true, + standing: true, + }), + (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) => + useTransaction((tx) => + tx + .select() + .from(polarTable) + .where(and(eq(polarTable.customerID, customerID))) + .execute() + .then((rows) => rows.map(serialize).at(0)), + ), + ); + + function serialize( + input: typeof polarTable.$inferSelect, + ): z.infer { + return { + teamID: input.teamID, + customerID: input.customerID, + subscriptionID: input.subscriptionID, + subscriptionItemID: input.subscriptionItemID, + standing: input.standing, + }; + } +} \ No newline at end of file diff --git a/packages/core/src/polar/polar.sql.ts b/packages/core/src/polar/polar.sql.ts new file mode 100644 index 00000000..6e28748c --- /dev/null +++ b/packages/core/src/polar/polar.sql.ts @@ -0,0 +1,22 @@ +import { timestamps, teamID } from "../drizzle/types"; +import { teamIndexes, teamTable } from "../team/team.sql"; +import { pgTable, text, varchar } from "drizzle-orm/pg-core"; + +export const Standing = ["new", "good", "overdue"] as const; + +export const polarTable = pgTable( + "polar", + { + teamID: teamID.teamID.primaryKey().references(() => teamTable.id), + ...timestamps, + customerID: varchar("customer_id", { length: 255 }).notNull(), + subscriptionID: varchar("subscription_id", { length: 255 }), + subscriptionItemID: varchar("subscription_item_id", { + length: 255, + }), + standing: text("standing", { enum: Standing }).notNull(), + }, + (table) => ({ + ...teamIndexes(table), + }) +) \ No newline at end of file diff --git a/packages/core/src/team/index.ts b/packages/core/src/team/index.ts index 1db157bc..3d7a882e 100644 --- a/packages/core/src/team/index.ts +++ b/packages/core/src/team/index.ts @@ -3,13 +3,13 @@ import { Resource } from "sst"; import { bus } from "sst/aws/bus"; import { Common } from "../common"; import { createID, fn } from "../utils"; -import { VisibleError } from "../error"; import { Examples } from "../examples"; import { teamTable } from "./team.sql"; import { createEvent } from "../event"; -import { assertActor } from "../actor"; +import { assertActor, withActor } from "../actor"; import { and, eq, sql } from "../drizzle"; import { memberTable } from "../member/member.sql"; +import { HTTPException } from 'hono/http-exception'; import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction"; export module Team { @@ -45,11 +45,11 @@ export module Team { ), }; - export class WorkspaceExistsError extends VisibleError { + export class TeamExistsError extends HTTPException { constructor(slug: string) { super( - "team.slug_exists", - `there is already a workspace named "${slug}"`, + 400, + { message: `There is already a team named "${slug}"`, } ); } } @@ -65,15 +65,16 @@ export module Team { slug: input.slug, name: input.name }) - .onConflictDoNothing() - .returning({ insertedID: teamTable.id }) + .onConflictDoNothing({ target: teamTable.slug }) - if (result.length === 0) throw new WorkspaceExistsError(input.slug); + if (!result.rowCount) throw new TeamExistsError(input.slug); await afterTx(() => - bus.publish(Resource.Bus, Events.Created, { - teamID: id, - }), + withActor({ type: "system", properties: { teamID: id } }, () => + bus.publish(Resource.Bus, Events.Created, { + teamID: id, + }) + ), ); return id; }) diff --git a/packages/core/src/user/index.ts b/packages/core/src/user/index.ts index 439e1590..a27f1ad2 100644 --- a/packages/core/src/user/index.ts +++ b/packages/core/src/user/index.ts @@ -1,4 +1,6 @@ import { z } from "zod"; +import { Polar } from "../polar"; +import { Team } from "../team"; import { bus } from "sst/aws/bus"; import { Common } from "../common"; import { createID, fn } from "../utils"; @@ -11,7 +13,6 @@ import { assertActor, withActor } from "../actor"; import { memberTable } from "../member/member.sql"; import { and, eq, isNull, asc, getTableColumns, sql } from "../drizzle"; import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction"; -import { Team } from "../team"; export module User { @@ -106,12 +107,8 @@ export module User { //FIXME: Do this much later, as Polar.sh has so many inconsistencies for fuck's sake - // const customer = await Polar.client.customers.create({ - // email: input.email, - // metadata: { - // userID, - // }, - // }); + const customer = await Polar.fromUserEmail(input.email) + console.log("customer", customer) const name = sanitizeUsername(input.name); @@ -131,6 +128,7 @@ export module User { avatarUrl: input.avatarUrl, email: input.email, discriminator: Number(discriminator), + polarCustomerID: customer?.id }) await afterTx(() => withActor({ diff --git a/packages/functions/package.json b/packages/functions/package.json index 29ed020f..88e2baa4 100644 --- a/packages/functions/package.json +++ b/packages/functions/package.json @@ -14,7 +14,7 @@ "typescript": "^5.0.0" }, "dependencies": { - "@openauthjs/openauth": "^0.3.9", + "@openauthjs/openauth": "0.4.3", "hono": "^4.6.15", "hono-openapi": "^0.3.1", "partysocket": "1.0.3" diff --git a/packages/functions/src/api/account.ts b/packages/functions/src/api/account.ts index 32ce1de7..c5ab6163 100644 --- a/packages/functions/src/api/account.ts +++ b/packages/functions/src/api/account.ts @@ -42,8 +42,10 @@ export module AccountApi { }), async (c) => { const actor = assertActor("user"); - const currentUser = await User.fromID(actor.properties.userID) - if (!currentUser) return c.json({ error: "This account does not exist, it may have been deleted" }, 404) + const [currentUser, teams] = await Promise.all([User.fromID(actor.properties.userID), User.teams()]) + + if (!currentUser) return c.json({ error: "This account does not exist; it may have been deleted" }, 404) + const { id, email, name, polarCustomerID, avatarUrl, discriminator } = currentUser return c.json({ @@ -51,10 +53,10 @@ export module AccountApi { id, email, name, + teams, avatarUrl, discriminator, polarCustomerID, - teams: await User.teams(), } }, 200); }, diff --git a/packages/server/Cargo.lock b/packages/server/Cargo.lock index eca99bbb..579948cc 100644 --- a/packages/server/Cargo.lock +++ b/packages/server/Cargo.lock @@ -1876,7 +1876,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" dependencies = [ "cfg-if", - "windows-targets 0.52.6", + "windows-targets 0.48.5", ] [[package]] @@ -2668,9 +2668,9 @@ dependencies = [ [[package]] name = "ring" -version = "0.17.11" +version = "0.17.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da5349ae27d3887ca812fb375b45a4fbb36d8d12d2df394968cd86e35683fe73" +checksum = "70ac5d832aa16abd7d1def883a8545280c20a60f523a370aa3a9617c2b8550ee" dependencies = [ "cc", "cfg-if", diff --git a/packages/www/index.html b/packages/www/index.html index 3ec7a691..536f6e06 100644 --- a/packages/www/index.html +++ b/packages/www/index.html @@ -9,12 +9,12 @@ { ( - // - // {props.children} - props.children - // + + {props.children} + + // props.children )} > - + { diff --git a/packages/www/src/pages/default-state.tsx b/packages/www/src/pages/default-state.tsx deleted file mode 100644 index c13befc0..00000000 --- a/packages/www/src/pages/default-state.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export function DefaultState() { - return ( -
- We are logging you in -
- ) -} \ No newline at end of file diff --git a/packages/www/src/pages/new.tsx b/packages/www/src/pages/new.tsx index 3319de5b..4b926763 100644 --- a/packages/www/src/pages/new.tsx +++ b/packages/www/src/pages/new.tsx @@ -1,15 +1,168 @@ +import * as v from "valibot" +import { styled } from "@macaron-css/solid"; import { Text } from "@nestri/www/ui/text"; +import { utility } from "@nestri/www/ui/utility"; +import { theme } from "@nestri/www/ui/theme"; +import { FormField, Input, Select } from "@nestri/www/ui/form"; import { Container, FullScreen } from "@nestri/www/ui/layout"; +import { createForm, required, email, valiForm } from "@modular-forms/solid"; +import { Button } from "@nestri/www/ui"; + +// const nameRegex = /^[a-z]+$/ + +const FieldList = styled("div", { + base: { + width: "100%", + maxWidth: 380, + ...utility.stack(5), + }, +}); + +const Hr = styled("hr", { + base: { + border: 0, + backgroundColor: theme.color.gray.d400, + width: "100%", + height: 1, + } +}) + +const Plan = { + Pro: 'BYOG', + Basic: 'Hosted', +} as const; + +const schema = v.object({ + plan: v.pipe( + v.enum(Plan), + v.minLength(2,"Please choose a plan"), + ), + display_name: v.pipe( + v.string(), + v.maxLength(32, 'Please use 32 characters at maximum.'), + ), + slug: v.pipe( + v.string(), + v.minLength(2, 'Please use 2 characters at minimum.'), + // v.regex(nameRegex, "Use only small letters, no numbers or special characters"), + v.maxLength(48, 'Please use 48 characters at maximum.'), + ) +}) + +// const Details = styled("details", { +// base: { +// overflow: "hidden", +// transition: "max-height .2s ease" +// } +// }) + +// const Summary = styled("summary", { +// base: { +// userSelect: "none", +// cursor: "pointer", +// listStyle: "none" +// } +// }) + +// const SVG = styled("svg", { +// base: { +// color: theme.color.gray.d900, +// width: 20, +// height: 20, +// marginRight: theme.space[2] +// } +// }) + +// const Subtitle = styled("p", { +// base: { +// color: theme.color.gray.d900, +// fontSize: theme.font.size.sm, +// fontWeight: theme.font.weight.regular, +// lineHeight: "1rem" +// } +// }) + +export function CreateTeamComponent() { + const [form, { Form, Field }] = createForm({ + validate: valiForm(schema), + }); -export function TeamCreate() { return ( - - - Your first deploy is just a sign-up away. - - + + + + Create a Team + + + Choose something that your teammates will recognize + +
+
+
+ + + {(field, props) => ( + + + + )} + + + {(field, props) => ( + +