mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 16:55:37 +02:00
⭐ 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:
@@ -109,43 +109,34 @@ export function assertActor<T extends Actor["type"]>(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() {
|
||||
const actor = useActor();
|
||||
if ("teamID" in actor.properties) return actor.properties.teamID;
|
||||
throw new Error(`Expected actor to have teamID`);
|
||||
}
|
||||
|
||||
export function useMachine() {
|
||||
const actor = useActor();
|
||||
if ("machineID" in actor.properties) return actor.properties.fingerprint;
|
||||
throw new Error(`Expected actor to have fingerprint`);
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`Expected actor to have teamID`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the current user possesses the specified flag.
|
||||
* Returns the fingerprint of the current actor if the actor has a machine identity.
|
||||
*
|
||||
* This function executes a database transaction that queries the user table for the current user's flags.
|
||||
* If the flags are missing, it throws a {@link VisibleError} with the code {@link ErrorCodes.Validation.MISSING_REQUIRED_FIELD}
|
||||
* and a message indicating that the required flag is absent.
|
||||
*
|
||||
* @param flag - The name of the user flag to verify.
|
||||
*
|
||||
* @throws {VisibleError} If the user's flag is missing.
|
||||
* @returns The fingerprint of the current machine actor.
|
||||
* @throws {VisibleError} If the current actor does not have a machine identity.
|
||||
*/
|
||||
export async function assertUserFlag(flag: keyof UserFlags) {
|
||||
return useTransaction((tx) =>
|
||||
tx
|
||||
.select({ flags: userTable.flags })
|
||||
.from(userTable)
|
||||
.where(eq(userTable.id, useUserID()))
|
||||
.then((rows) => {
|
||||
const flags = rows[0]?.flags;
|
||||
if (!flags)
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.Validation.MISSING_REQUIRED_FIELD,
|
||||
"Actor does not have " + flag + " flag",
|
||||
);
|
||||
}),
|
||||
export function useMachine() {
|
||||
const actor = useActor();
|
||||
if ("machineID" in actor.properties) return actor.properties.fingerprint;
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`Expected actor to have fingerprint`
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import "zod-openapi/extend";
|
||||
|
||||
export namespace Common {
|
||||
export const IdDescription = `Unique object identifier.
|
||||
The format and length of IDs may change over time.`;
|
||||
|
||||
export const now = () => sql`now()`;
|
||||
export const utc = () => sql`now() at time zone 'utc'`;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { char, timestamp as rawTs } from "drizzle-orm/pg-core";
|
||||
import { teamTable } from "../team/team.sql";
|
||||
|
||||
export const ulid = (name: string) => char(name, { length: 26 + 4 });
|
||||
|
||||
|
||||
@@ -34,23 +34,38 @@ export namespace Examples {
|
||||
steamAccounts: [Steam]
|
||||
};
|
||||
|
||||
export const Team = {
|
||||
id: Id("team"),
|
||||
name: "John Does' Team",
|
||||
slug: "john_doe",
|
||||
planType: "BYOG" as const
|
||||
export const Product = {
|
||||
id: Id("product"),
|
||||
name: "RTX 4090",
|
||||
description: "Ideal for dedicated gamers who crave more flexibility and social gaming experiences.",
|
||||
tokensPerHour: 20,
|
||||
}
|
||||
|
||||
export const Subscription = {
|
||||
tokens: 100,
|
||||
id: Id("subscription"),
|
||||
userID: Id("user"),
|
||||
teamID: Id("team"),
|
||||
planType: "pro" as const, // free, pro, family, enterprise
|
||||
standing: "new" as const, // new, good, overdue, cancelled
|
||||
polarProductID: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
polarSubscriptionID: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
}
|
||||
|
||||
export const Member = {
|
||||
id: Id("member"),
|
||||
email: "john@example.com",
|
||||
teamID: Id("team"),
|
||||
role: "admin" as const,
|
||||
timeSeen: new Date("2025-02-23T13:39:52.249Z"),
|
||||
}
|
||||
|
||||
export const Polar = {
|
||||
teamID: Id("team"),
|
||||
timeSeen: new Date("2025-02-23T13:39:52.249Z"),
|
||||
export const Team = {
|
||||
id: Id("team"),
|
||||
name: "John Does' Team",
|
||||
slug: "john_doe",
|
||||
subscriptions: [Subscription],
|
||||
members: [Member]
|
||||
}
|
||||
|
||||
export const Machine = {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Common } from "../common";
|
||||
import { createID, fn } from "../utils";
|
||||
import { createEvent } from "../event";
|
||||
import { Examples } from "../examples";
|
||||
import { memberTable } from "./member.sql";
|
||||
import { memberTable, role } from "./member.sql";
|
||||
import { and, eq, sql, asc, isNull } from "../drizzle";
|
||||
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
@@ -17,7 +17,7 @@ export namespace Member {
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Member.id,
|
||||
}),
|
||||
timeSeen: z.date().or(z.null()).openapi({
|
||||
timeSeen: z.date().nullable().or(z.undefined()).openapi({
|
||||
description: "The last time this team member was active",
|
||||
example: Examples.Member.timeSeen
|
||||
}),
|
||||
@@ -25,6 +25,10 @@ export namespace Member {
|
||||
description: "The unique id of the team this member is on",
|
||||
example: Examples.Member.teamID
|
||||
}),
|
||||
role: z.enum(role).openapi({
|
||||
description: "The role of this team member",
|
||||
example: Examples.Member.role
|
||||
}),
|
||||
email: z.string().openapi({
|
||||
description: "The email of this team member",
|
||||
example: Examples.Member.email
|
||||
@@ -68,6 +72,7 @@ export namespace Member {
|
||||
id,
|
||||
teamID: useTeam(),
|
||||
email: input.email,
|
||||
role: input.first ? "owner" : "member",
|
||||
timeSeen: input.first ? sql`now()` : null,
|
||||
})
|
||||
|
||||
@@ -113,11 +118,18 @@ export namespace Member {
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Converts a raw member database row into a standardized {@link Member.Info} object.
|
||||
*
|
||||
* @param input - The database row representing a member.
|
||||
* @returns The member information formatted as a {@link Member.Info} object.
|
||||
*/
|
||||
export function serialize(
|
||||
input: typeof memberTable.$inferSelect,
|
||||
): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
role: input.role,
|
||||
email: input.email,
|
||||
teamID: input.teamID,
|
||||
timeSeen: input.timeSeen
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { teamIndexes } from "../team/team.sql";
|
||||
import { timestamps, utc, teamID } from "../drizzle/types";
|
||||
import { index, pgTable, uniqueIndex, varchar } from "drizzle-orm/pg-core";
|
||||
import { index, pgTable, text, uniqueIndex, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const role = ["admin", "member", "owner"] as const;
|
||||
|
||||
export const memberTable = pgTable(
|
||||
"member",
|
||||
{
|
||||
...teamID,
|
||||
...timestamps,
|
||||
role: text("role", { enum: role }).notNull(),
|
||||
timeSeen: utc("time_seen"),
|
||||
email: varchar("email", { length: 255 }).notNull(),
|
||||
},
|
||||
|
||||
@@ -1,69 +1,16 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Resource } from "sst";
|
||||
import { eq, and } from "../drizzle";
|
||||
import { useTeam } from "../actor";
|
||||
import { createEvent } from "../event";
|
||||
// import { polarTable, Standing } from "./polar.sql.ts.test";
|
||||
import { useTeam, useUserID } from "../actor";
|
||||
import { Polar as PolarSdk } from "@polar-sh/sdk";
|
||||
import { useTransaction } from "../drizzle/transaction";
|
||||
import { validateEvent } from "@polar-sh/sdk/webhooks";
|
||||
import { PlanType } from "../subscription/subscription.sql";
|
||||
|
||||
const polar = new PolarSdk({ accessToken: Resource.PolarSecret.value, server: Resource.App.stage !== "production" ? "sandbox" : "production" });
|
||||
|
||||
const planType = z.enum(PlanType)
|
||||
export namespace Polar {
|
||||
export const client = polar;
|
||||
|
||||
export const Info = z.object({
|
||||
teamID: z.string(),
|
||||
subscriptionID: z.string().nullable(),
|
||||
customerID: z.string(),
|
||||
subscriptionItemID: z.string().nullable(),
|
||||
// standing: z.enum(Standing),
|
||||
});
|
||||
|
||||
export type Info = z.infer<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) => {
|
||||
try {
|
||||
const customers = await client.customers.list({ email })
|
||||
@@ -81,89 +28,69 @@ export namespace Polar {
|
||||
}
|
||||
})
|
||||
|
||||
// export const setCustomerID = fn(Info.shape.customerID, async (customerID) =>
|
||||
// useTransaction(async (tx) =>
|
||||
// tx
|
||||
// .insert(polarTable)
|
||||
// .values({
|
||||
// teamID: useTeam(),
|
||||
// customerID,
|
||||
// standing: "new",
|
||||
// })
|
||||
// .execute(),
|
||||
// ),
|
||||
// );
|
||||
const getProductIDs = (plan: z.infer<typeof planType>) => {
|
||||
switch (plan) {
|
||||
case "free":
|
||||
return [Resource.NestriFreeMonthly.value]
|
||||
case "pro":
|
||||
return [Resource.NestriProYearly.value, Resource.NestriProMonthly.value]
|
||||
case "family":
|
||||
return [Resource.NestriFamilyYearly.value, Resource.NestriFamilyMonthly.value]
|
||||
default:
|
||||
return [Resource.NestriFreeMonthly.value]
|
||||
}
|
||||
}
|
||||
|
||||
// export const setSubscription = fn(
|
||||
// Info.pick({
|
||||
// subscriptionID: true,
|
||||
// subscriptionItemID: true,
|
||||
// }),
|
||||
// (input) =>
|
||||
// useTransaction(async (tx) =>
|
||||
// tx
|
||||
// .update(polarTable)
|
||||
// .set({
|
||||
// subscriptionID: input.subscriptionID,
|
||||
// subscriptionItemID: input.subscriptionItemID,
|
||||
// })
|
||||
// .where(eq(polarTable.teamID, useTeam()))
|
||||
// .returning()
|
||||
// .execute()
|
||||
// .then((rows) => rows.map(serialize).at(0)),
|
||||
// ),
|
||||
// );
|
||||
export const createPortal = fn(
|
||||
z.string(),
|
||||
async (customerId) => {
|
||||
const session = await client.customerSessions.create({
|
||||
customerId
|
||||
})
|
||||
|
||||
// export const removeSubscription = fn(
|
||||
// z.string().min(1),
|
||||
// (stripeSubscriptionID) =>
|
||||
// useTransaction((tx) =>
|
||||
// tx
|
||||
// .update(polarTable)
|
||||
// .set({
|
||||
// subscriptionItemID: null,
|
||||
// subscriptionID: null,
|
||||
// })
|
||||
// .where(and(eq(polarTable.subscriptionID, stripeSubscriptionID)))
|
||||
// .execute(),
|
||||
// ),
|
||||
// );
|
||||
return session.customerPortalUrl
|
||||
}
|
||||
)
|
||||
|
||||
// export const setStanding = fn(
|
||||
// Info.pick({
|
||||
// subscriptionID: true,
|
||||
// standing: true,
|
||||
// }),
|
||||
// (input) =>
|
||||
// useTransaction((tx) =>
|
||||
// tx
|
||||
// .update(polarTable)
|
||||
// .set({ standing: input.standing })
|
||||
// .where(and(eq(polarTable.subscriptionID, input.subscriptionID!)))
|
||||
// .execute(),
|
||||
// ),
|
||||
// );
|
||||
//TODO: Implement this
|
||||
export const handleWebhook = async(payload: ReturnType<typeof validateEvent>) => {
|
||||
switch (payload.type) {
|
||||
case "subscription.created":
|
||||
const teamID = payload.data.metadata.teamID
|
||||
}
|
||||
}
|
||||
|
||||
// export const fromCustomerID = fn(Info.shape.customerID, (customerID) =>
|
||||
// useTransaction((tx) =>
|
||||
// tx
|
||||
// .select()
|
||||
// .from(polarTable)
|
||||
// .where(and(eq(polarTable.customerID, customerID)))
|
||||
// .execute()
|
||||
// .then((rows) => rows.map(serialize).at(0)),
|
||||
// ),
|
||||
// );
|
||||
export const createCheckout = fn(
|
||||
z
|
||||
.object({
|
||||
planType: z.enum(PlanType),
|
||||
customerEmail: z.string(),
|
||||
successUrl: z.string(),
|
||||
customerID: z.string(),
|
||||
allowDiscountCodes: z.boolean(),
|
||||
teamID: z.string()
|
||||
})
|
||||
.partial({
|
||||
customerEmail: true,
|
||||
allowDiscountCodes: true,
|
||||
customerID: true,
|
||||
teamID: true
|
||||
}),
|
||||
async (input) => {
|
||||
const productIDs = getProductIDs(input.planType)
|
||||
|
||||
// function serialize(
|
||||
// input: typeof polarTable.$inferSelect,
|
||||
// ): z.infer<typeof Info> {
|
||||
// return {
|
||||
// teamID: input.teamID,
|
||||
// customerID: input.customerID,
|
||||
// subscriptionID: input.subscriptionID,
|
||||
// subscriptionItemID: input.subscriptionItemID,
|
||||
// standing: input.standing,
|
||||
// };
|
||||
// }
|
||||
const checkoutUrl =
|
||||
await client.checkouts.create({
|
||||
products: productIDs,
|
||||
customerEmail: input.customerEmail ?? useUserID(),
|
||||
successUrl: `${input.successUrl}?checkout={CHECKOUT_ID}`,
|
||||
allowDiscountCodes: input.allowDiscountCodes ?? false,
|
||||
customerId: input.customerID,
|
||||
customerMetadata: {
|
||||
teamID: input.teamID ?? useTeam()
|
||||
}
|
||||
})
|
||||
|
||||
return checkoutUrl.url
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
]
|
||||
)
|
||||
@@ -1,24 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { id, timestamps, ulid, userID, utc } from "../drizzle/types";
|
||||
import { index, pgTable, integer, uniqueIndex, varchar, text, primaryKey, json } from "drizzle-orm/pg-core";
|
||||
import { userTable } from "../user/user.sql";
|
||||
|
||||
|
||||
// public string Username { get; set; } = string.Empty;
|
||||
// public ulong SteamId { get; set; }
|
||||
// public string Email { get; set; } = string.Empty;
|
||||
// public string Country { get; set; } = string.Empty;
|
||||
// public string PersonaName { get; set; } = string.Empty;
|
||||
// public string AvatarUrl { get; set; } = string.Empty;
|
||||
// public bool IsLimited { get; set; }
|
||||
// public bool IsLocked { get; set; }
|
||||
// public bool IsBanned { get; set; }
|
||||
// public bool IsAllowedToInviteFriends { get; set; }
|
||||
// public ulong GameId { get; set; }
|
||||
// public string GamePlayingName { get; set; } = string.Empty;
|
||||
// public DateTime LastLogOn { get; set; }
|
||||
// public DateTime LastLogOff { get; set; }
|
||||
// public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
import { id, timestamps, ulid, utc } from "../drizzle/types";
|
||||
import { index, pgTable, integer, uniqueIndex, varchar, text, json } from "drizzle-orm/pg-core";
|
||||
|
||||
export const LastGame = z.object({
|
||||
gameID: z.number(),
|
||||
@@ -54,5 +37,9 @@ export const steamTable = pgTable(
|
||||
steamEmail: varchar("steam_email", { length: 255 }).notNull(),
|
||||
personaName: varchar("persona_name", { length: 255 }).notNull(),
|
||||
limitation: json("limitation").$type<AccountLimitation>().notNull(),
|
||||
}
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("steam_id").on(table.steamID),
|
||||
index("steam_user_id").on(table.userID),
|
||||
],
|
||||
);
|
||||
192
packages/core/src/subscription/index.ts
Normal file
192
packages/core/src/subscription/index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
31
packages/core/src/subscription/subscription.sql.ts
Normal file
31
packages/core/src/subscription/subscription.sql.ts
Normal 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]
|
||||
}),
|
||||
]
|
||||
)
|
||||
@@ -1,16 +1,18 @@
|
||||
import { z } from "zod";
|
||||
import { Resource } from "sst";
|
||||
import { bus } from "sst/aws/bus";
|
||||
import { Common } from "../common";
|
||||
import { Member } from "../member";
|
||||
import { teamTable } from "./team.sql";
|
||||
import { Examples } from "../examples";
|
||||
import { assertActor } from "../actor";
|
||||
import { createEvent } from "../event";
|
||||
import { createID, fn } from "../utils";
|
||||
import { Subscription } from "../subscription";
|
||||
import { and, eq, sql, isNull } from "../drizzle";
|
||||
import { PlanType, teamTable } from "./team.sql";
|
||||
import { assertActor, withActor } from "../actor";
|
||||
import { memberTable } from "../member/member.sql";
|
||||
import { ErrorCodes, VisibleError } from "../error";
|
||||
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
import { groupBy, map, pipe, values } from "remeda";
|
||||
import { subscriptionTable } from "../subscription/subscription.sql";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Team {
|
||||
export const Info = z
|
||||
@@ -19,6 +21,7 @@ export namespace Team {
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Team.id,
|
||||
}),
|
||||
// Remove spaces and make sure it is lowercase (this is just to make sure the frontend did this)
|
||||
slug: z.string().regex(/^[a-z0-9\-]+$/, "Use a URL friendly name.").openapi({
|
||||
description: "The unique and url-friendly slug of this team",
|
||||
example: Examples.Team.slug
|
||||
@@ -27,10 +30,14 @@ export namespace Team {
|
||||
description: "The name of this team",
|
||||
example: Examples.Team.name
|
||||
}),
|
||||
planType: z.enum(PlanType).openapi({
|
||||
description: "The type of Plan this team is subscribed to",
|
||||
example: Examples.Team.planType
|
||||
})
|
||||
members: Member.Info.array().openapi({
|
||||
description: "The members of this team",
|
||||
example: Examples.Team.members
|
||||
}),
|
||||
subscriptions: Subscription.Info.array().openapi({
|
||||
description: "The subscriptions of this team",
|
||||
example: Examples.Team.subscriptions
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Team",
|
||||
@@ -60,16 +67,14 @@ export namespace Team {
|
||||
}
|
||||
|
||||
export const create = fn(
|
||||
Info.pick({ slug: true, id: true, name: true, planType: true }).partial({
|
||||
Info.pick({ slug: true, id: true, name: true, }).partial({
|
||||
id: true,
|
||||
}), (input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const id = input.id ?? createID("team");
|
||||
const result = await tx.insert(teamTable).values({
|
||||
id,
|
||||
//Remove spaces and make sure it is lowercase (this is just to make sure the frontend did this)
|
||||
slug: input.slug, //.toLowerCase().replace(/[\s]/g, ''),
|
||||
planType: input.planType,
|
||||
slug: input.slug,
|
||||
name: input.name
|
||||
})
|
||||
.onConflictDoNothing({ target: teamTable.slug })
|
||||
@@ -80,6 +85,7 @@ export namespace Team {
|
||||
})
|
||||
)
|
||||
|
||||
//TODO: "Delete" subscription and member(s) as well
|
||||
export const remove = fn(Info.shape.id, (input) =>
|
||||
useTransaction(async (tx) => {
|
||||
const account = assertActor("user");
|
||||
@@ -106,48 +112,107 @@ export namespace Team {
|
||||
}),
|
||||
);
|
||||
|
||||
export const list = fn(z.void(), () =>
|
||||
useTransaction((tx) =>
|
||||
export const list = fn(z.void(), () => {
|
||||
const actor = assertActor("user");
|
||||
return useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(teamTable)
|
||||
.where(isNull(teamTable.timeDeleted))
|
||||
.leftJoin(subscriptionTable, eq(subscriptionTable.teamID, teamTable.id))
|
||||
.innerJoin(memberTable, eq(memberTable.teamID, teamTable.id))
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.email, actor.properties.email),
|
||||
isNull(memberTable.timeDeleted),
|
||||
isNull(teamTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.execute()
|
||||
.then((rows) => rows.map(serialize)),
|
||||
.then((rows) => serialize(rows))
|
||||
)
|
||||
});
|
||||
|
||||
export const fromID = fn(z.string().min(1), async (id) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(teamTable)
|
||||
.leftJoin(subscriptionTable, eq(subscriptionTable.teamID, teamTable.id))
|
||||
.innerJoin(memberTable, eq(memberTable.teamID, teamTable.id))
|
||||
.where(
|
||||
and(
|
||||
eq(teamTable.id, id),
|
||||
isNull(memberTable.timeDeleted),
|
||||
isNull(teamTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.execute()
|
||||
.then((rows) => serialize(rows).at(0))
|
||||
),
|
||||
);
|
||||
|
||||
export const fromID = fn(z.string().min(1), async (id) =>
|
||||
useTransaction(async (tx) => {
|
||||
return tx
|
||||
export const fromSlug = fn(z.string().min(1), async (slug) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(teamTable)
|
||||
.where(and(eq(teamTable.id, id), isNull(teamTable.timeDeleted)))
|
||||
.leftJoin(subscriptionTable, eq(subscriptionTable.teamID, teamTable.id))
|
||||
.innerJoin(memberTable, eq(memberTable.teamID, teamTable.id))
|
||||
.where(
|
||||
and(
|
||||
eq(teamTable.slug, slug),
|
||||
isNull(memberTable.timeDeleted),
|
||||
isNull(teamTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.execute()
|
||||
.then((rows) => rows.map(serialize).at(0))
|
||||
}),
|
||||
);
|
||||
|
||||
export const fromSlug = fn(z.string().min(1), async (input) =>
|
||||
useTransaction(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(teamTable)
|
||||
.where(and(eq(teamTable.slug, input), isNull(teamTable.timeDeleted)))
|
||||
.execute()
|
||||
.then((rows) => rows.map(serialize).at(0))
|
||||
}),
|
||||
.then((rows) => serialize(rows).at(0))
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Transforms an array of team, subscription, and member records into structured team objects.
|
||||
*
|
||||
* Groups input rows by team ID and constructs an array of team objects, each including its associated members and subscriptions.
|
||||
*
|
||||
* @param input - Array of objects containing team, subscription, and member data.
|
||||
* @returns An array of team objects with their members and subscriptions.
|
||||
*/
|
||||
export function serialize(
|
||||
input: typeof teamTable.$inferSelect,
|
||||
): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
planType: input.planType,
|
||||
};
|
||||
input: { team: typeof teamTable.$inferSelect, subscription: typeof subscriptionTable.$inferInsert | null, member: typeof memberTable.$inferInsert | null }[],
|
||||
): z.infer<typeof Info>[] {
|
||||
console.log("serialize", input)
|
||||
return pipe(
|
||||
input,
|
||||
groupBy((row) => row.team.id),
|
||||
values(),
|
||||
map((group) => ({
|
||||
name: group[0].team.name,
|
||||
id: group[0].team.id,
|
||||
slug: group[0].team.slug,
|
||||
subscriptions: !group[0].subscription ?
|
||||
[] :
|
||||
group.map((row) => ({
|
||||
planType: row.subscription!.planType,
|
||||
polarProductID: row.subscription!.polarProductID,
|
||||
polarSubscriptionID: row.subscription!.polarSubscriptionID,
|
||||
standing: row.subscription!.standing,
|
||||
tokens: row.subscription!.tokens,
|
||||
teamID: row.subscription!.teamID,
|
||||
userID: row.subscription!.userID,
|
||||
id: row.subscription!.id,
|
||||
})),
|
||||
members:
|
||||
!group[0].member ?
|
||||
[] :
|
||||
group.map((row) => ({
|
||||
id: row.member!.id,
|
||||
email: row.member!.email,
|
||||
role: row.member!.role,
|
||||
teamID: row.member!.teamID,
|
||||
timeSeen: row.member!.timeSeen,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,15 +1,11 @@
|
||||
import { } from "drizzle-orm/postgres-js";
|
||||
import { timestamps, id } from "../drizzle/types";
|
||||
import {
|
||||
varchar,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
uniqueIndex,
|
||||
text
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
export const PlanType = ["Hosted", "BYOG"] as const;
|
||||
|
||||
export const teamTable = pgTable(
|
||||
"team",
|
||||
{
|
||||
@@ -17,7 +13,6 @@ export const teamTable = pgTable(
|
||||
...timestamps,
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
slug: varchar("slug", { length: 255 }).notNull(),
|
||||
planType: text("plan_type", { enum: PlanType }).notNull()
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("slug").on(table.slug)
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { z } from "zod";
|
||||
import { Team } from "../team";
|
||||
import { bus } from "sst/aws/bus";
|
||||
import { Steam } from "../steam";
|
||||
import { Common } from "../common";
|
||||
import { Polar } from "../polar/index";
|
||||
import { createID, fn } from "../utils";
|
||||
import { userTable } from "./user.sql";
|
||||
import { createEvent } from "../event";
|
||||
import { pipe, groupBy, values, map } from "remeda";
|
||||
import { Examples } from "../examples";
|
||||
import { Resource } from "sst/resource";
|
||||
import { teamTable } from "../team/team.sql";
|
||||
import { steamTable } from "../steam/steam.sql";
|
||||
import { assertActor, withActor } from "../actor";
|
||||
import { memberTable } from "../member/member.sql";
|
||||
import { and, eq, isNull, asc, getTableColumns, sql } from "../drizzle";
|
||||
import { pipe, groupBy, values, map } from "remeda";
|
||||
import { and, eq, isNull, asc, sql } from "../drizzle";
|
||||
import { subscriptionTable } from "../subscription/subscription.sql";
|
||||
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
import { Steam } from "../steam";
|
||||
|
||||
|
||||
export namespace User {
|
||||
@@ -154,91 +155,27 @@ export namespace User {
|
||||
})
|
||||
|
||||
export const fromEmail = fn(z.string(), async (email) =>
|
||||
useTransaction(async (tx) => {
|
||||
const rows = await tx
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(userTable)
|
||||
.leftJoin(steamTable, eq(userTable.id, steamTable.userID))
|
||||
.where(and(eq(userTable.email, email), isNull(userTable.timeDeleted)))
|
||||
.orderBy(asc(userTable.timeCreated))
|
||||
|
||||
const result = pipe(
|
||||
rows,
|
||||
groupBy((row) => row.user.id),
|
||||
values(),
|
||||
map(
|
||||
(group): Info => ({
|
||||
id: group[0].user.id,
|
||||
name: group[0].user.name,
|
||||
email: group[0].user.email,
|
||||
avatarUrl: group[0].user.avatarUrl,
|
||||
discriminator: group[0].user.discriminator,
|
||||
polarCustomerID: group[0].user.polarCustomerID,
|
||||
steamAccounts: !group[0].steam ?
|
||||
[] :
|
||||
group.map((row) => ({
|
||||
id: row.steam!.id,
|
||||
userID: row.steam!.userID,
|
||||
steamID: row.steam!.steamID,
|
||||
lastSeen: row.steam!.lastSeen,
|
||||
avatarUrl: row.steam!.avatarUrl,
|
||||
lastGame: row.steam!.lastGame,
|
||||
username: row.steam!.username,
|
||||
countryCode: row.steam!.countryCode,
|
||||
steamEmail: row.steam!.steamEmail,
|
||||
personaName: row.steam!.personaName,
|
||||
limitation: row.steam!.limitation,
|
||||
})),
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
return result[0]
|
||||
}),
|
||||
.then((rows => serialize(rows).at(0)))
|
||||
)
|
||||
)
|
||||
|
||||
export const fromID = fn(z.string(), async (id) =>
|
||||
useTransaction(async (tx) => {
|
||||
const rows = await tx
|
||||
export const fromID = fn(z.string(), (id) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(userTable)
|
||||
.leftJoin(steamTable, eq(userTable.id, steamTable.userID))
|
||||
.where(and(eq(userTable.id, id), isNull(userTable.timeDeleted)))
|
||||
.where(and(eq(userTable.id, id), isNull(userTable.timeDeleted), isNull(steamTable.timeDeleted)))
|
||||
.orderBy(asc(userTable.timeCreated))
|
||||
|
||||
const result = pipe(
|
||||
rows,
|
||||
groupBy((row) => row.user.id),
|
||||
values(),
|
||||
map(
|
||||
(group): Info => ({
|
||||
id: group[0].user.id,
|
||||
name: group[0].user.name,
|
||||
email: group[0].user.email,
|
||||
avatarUrl: group[0].user.avatarUrl,
|
||||
discriminator: group[0].user.discriminator,
|
||||
polarCustomerID: group[0].user.polarCustomerID,
|
||||
steamAccounts: !group[0].steam ?
|
||||
[] :
|
||||
group.map((row) => ({
|
||||
id: row.steam!.id,
|
||||
userID: row.steam!.userID,
|
||||
steamID: row.steam!.steamID,
|
||||
lastSeen: row.steam!.lastSeen,
|
||||
avatarUrl: row.steam!.avatarUrl,
|
||||
lastGame: row.steam!.lastGame,
|
||||
username: row.steam!.username,
|
||||
countryCode: row.steam!.countryCode,
|
||||
steamEmail: row.steam!.steamEmail,
|
||||
personaName: row.steam!.personaName,
|
||||
limitation: row.steam!.limitation,
|
||||
})),
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
return result[0]
|
||||
}),
|
||||
.then((rows) => serialize(rows).at(0))
|
||||
),
|
||||
)
|
||||
|
||||
export const remove = fn(Info.shape.id, (id) =>
|
||||
@@ -254,12 +191,54 @@ export namespace User {
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Converts an array of user and Steam account records into structured user objects with associated Steam accounts.
|
||||
*
|
||||
* @param input - An array of objects containing user data and optional Steam account data.
|
||||
* @returns An array of user objects, each including a list of their associated Steam accounts.
|
||||
*/
|
||||
export function serialize(
|
||||
input: { user: typeof userTable.$inferSelect; steam: typeof steamTable.$inferSelect | null }[],
|
||||
): z.infer<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() {
|
||||
const actor = assertActor("user");
|
||||
return useTransaction((tx) =>
|
||||
return useTransaction(async (tx) =>
|
||||
tx
|
||||
.select(getTableColumns(teamTable))
|
||||
.select()
|
||||
.from(teamTable)
|
||||
.leftJoin(subscriptionTable, eq(subscriptionTable.teamID, teamTable.id))
|
||||
.innerJoin(memberTable, eq(memberTable.teamID, teamTable.id))
|
||||
.where(
|
||||
and(
|
||||
@@ -269,7 +248,7 @@ export namespace User {
|
||||
),
|
||||
)
|
||||
.execute()
|
||||
.then((rows) => rows.map(Team.serialize))
|
||||
);
|
||||
.then((rows) => Team.serialize(rows))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,14 @@ import { ulid } from "ulid";
|
||||
|
||||
export const prefixes = {
|
||||
user: "usr",
|
||||
team: "tea",
|
||||
team: "tem",
|
||||
task: "tsk",
|
||||
machine: "mch",
|
||||
member: "mbr",
|
||||
steam: "stm",
|
||||
subscription: "sub",
|
||||
invite: "inv",
|
||||
product: "prd",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user