feat(www): Finish up on the onboarding (#210)

Merging this prematurely to make sure the team is on the same boat... like dang! We need to find a better way to do this. 

Plus it has become too big
This commit is contained in:
Wanjohi
2025-03-26 02:21:53 +03:00
committed by GitHub
parent 957eca7794
commit f62fc1fb4b
106 changed files with 6329 additions and 866 deletions

View File

@@ -1,30 +1,17 @@
export * from "drizzle-orm";
import ws from 'ws';
import { Resource } from "sst";
import { drizzle as neonDrizzle, NeonDatabase } from "drizzle-orm/neon-serverless";
// import { drizzle } from 'drizzle-orm/postgres-js';
import { Pool, neonConfig } from "@neondatabase/serverless";
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
neonConfig.webSocketConstructor = ws;
const client = postgres({
idle_timeout: 30000,
connect_timeout: 30000,
host: Resource.Postgres.host,
database: Resource.Postgres.database,
user: Resource.Postgres.username,
password: Resource.Postgres.password,
port: Resource.Postgres.port,
max: parseInt(process.env.POSTGRES_POOL_MAX || "1"),
});
function addPoolerSuffix(original: string): string {
const firstDotIndex = original.indexOf('.');
if (firstDotIndex === -1) return original + '-pooler';
return original.slice(0, firstDotIndex) + '-pooler' + original.slice(firstDotIndex);
}
const dbHost = addPoolerSuffix(Resource.Database.host)
const client = new Pool({ connectionString: `postgres://${Resource.Database.user}:${Resource.Database.password}@${dbHost}/${Resource.Database.name}?sslmode=require` })
export const db = neonDrizzle(client, {
logger:
process.env.DRIZZLE_LOG === "true"
? {
logQuery(query, params) {
console.log("query", query);
console.log("params", params);
},
}
: undefined,
});
export const db = drizzle(client, {});

View File

@@ -4,14 +4,13 @@ import {
PgTransactionConfig
} from "drizzle-orm/pg-core";
import {
NeonQueryResultHKT
// NeonHttpQueryResultHKT
} from "drizzle-orm/neon-serverless";
PostgresJsQueryResultHKT
} from "drizzle-orm/postgres-js";
import { ExtractTablesWithRelations } from "drizzle-orm";
import { createContext } from "../context";
export type Transaction = PgTransaction<
NeonQueryResultHKT,
PostgresJsQueryResultHKT,
Record<string, never>,
ExtractTablesWithRelations<Record<string, never>>
>;
@@ -59,7 +58,6 @@ export async function createTransaction<T>(
},
);
await Promise.all(effects.map((x) => x()));
// await db.$client.end()
return result as T;
}
}

View File

@@ -1,8 +1,145 @@
import { z } from "zod"
/**
* Standard error response schema used for OpenAPI documentation
*/
export const ErrorResponse = z
.object({
type: z
.enum([
"validation",
"authentication",
"forbidden",
"not_found",
"already_exists",
"rate_limit",
"internal",
])
.openapi({
description: "The error type category",
examples: ["validation", "authentication"],
}),
code: z.string().openapi({
description: "Machine-readable error code identifier",
examples: ["invalid_parameter", "missing_required_field", "unauthorized"],
}),
message: z.string().openapi({
description: "Human-readable error message",
examples: ["The request was invalid", "Authentication required"],
}),
param: z
.string()
.optional()
.openapi({
description: "The parameter that caused the error (if applicable)",
examples: ["email", "user_id", "team_id"],
}),
details: z.any().optional().openapi({
description: "Additional error context information",
}),
})
.openapi({ ref: "ErrorResponse" });
export type ErrorResponseType = z.infer<typeof ErrorResponse>;
/**
* Standardized error codes for the API
*/
export const ErrorCodes = {
// Validation errors (400)
Validation: {
MISSING_REQUIRED_FIELD: "missing_required_field",
ALREADY_EXISTS: "resource_already_exists",
TEAM_ALREADY_EXISTS: "team_already_exists",
INVALID_PARAMETER: "invalid_parameter",
INVALID_FORMAT: "invalid_format",
INVALID_STATE: "invalid_state",
IN_USE: "resource_in_use",
},
// Authentication errors (401)
Authentication: {
UNAUTHORIZED: "unauthorized",
INVALID_TOKEN: "invalid_token",
EXPIRED_TOKEN: "expired_token",
INVALID_CREDENTIALS: "invalid_credentials",
},
// Permission errors (403)
Permission: {
FORBIDDEN: "forbidden",
INSUFFICIENT_PERMISSIONS: "insufficient_permissions",
ACCOUNT_RESTRICTED: "account_restricted",
},
// Resource not found errors (404)
NotFound: {
RESOURCE_NOT_FOUND: "resource_not_found",
},
// Rate limit errors (429)
RateLimit: {
TOO_MANY_REQUESTS: "too_many_requests",
QUOTA_EXCEEDED: "quota_exceeded",
},
// Server errors (500)
Server: {
INTERNAL_ERROR: "internal_error",
SERVICE_UNAVAILABLE: "service_unavailable",
DEPENDENCY_FAILURE: "dependency_failure",
},
};
/**
* Standard error that will be exposed to clients through API responses
*/
export class VisibleError extends Error {
constructor(
public code: string,
public message: string,
) {
super(message);
constructor(
public type: ErrorResponseType["type"],
public code: string,
public message: string,
public param?: string,
public details?: any,
) {
super(message);
}
/**
* Convert this error to an HTTP status code
*/
public statusCode(): number {
switch (this.type) {
case "validation":
return 400;
case "authentication":
return 401;
case "forbidden":
return 403;
case "not_found":
return 404;
case "already_exists":
return 409;
case "rate_limit":
return 429;
case "internal":
return 500;
}
}
}
/**
* Convert this error to a standard response object
*/
public toResponse(): ErrorResponseType {
const response: ErrorResponseType = {
type: this.type,
code: this.code,
message: this.message,
};
if (this.param) response.param = this.param;
if (this.details) response.details = this.details;
return response;
}
}

View File

@@ -16,6 +16,7 @@ export module Examples {
id: Id("team"),
name: "John Does' Team",
slug: "john_doe",
planType: "BYOG" as const
}
export const Member = {

View File

@@ -66,15 +66,11 @@ export module Member {
const id = input.id ?? createID("member");
await tx.insert(memberTable).values({
id,
email: input.email,
teamID: useTeam(),
timeSeen: input.first ? sql`CURRENT_TIMESTAMP()` : null,
}).onConflictDoUpdate({
target: memberTable.id,
set: {
timeDeleted: null,
}
email: input.email,
timeSeen: input.first ? sql`now()` : null,
})
await afterTx(() =>
async () => bus.publish(Resource.Bus, Events.Created, { memberID: id }),
);
@@ -87,7 +83,7 @@ export module Member {
await tx
.update(memberTable)
.set({
timeDeleted: sql`CURRENT_TIMESTAMP()`,
timeDeleted: sql`now()`,
})
.where(and(eq(memberTable.id, input), eq(memberTable.teamID, useTeam())))
.execute();

View File

@@ -12,7 +12,7 @@ export const memberTable = pgTable(
},
(table) => [
...teamIndexes(table),
uniqueIndex("member_email").on(table.teamID, table.email),
index("email_global").on(table.email),
uniqueIndex("member_email").on(table.teamID, table.email),
],
);

View File

@@ -4,7 +4,7 @@ import { Resource } from "sst";
import { eq, and } from "../drizzle";
import { useTeam } from "../actor";
import { createEvent } from "../event";
import { polarTable, Standing } from "./polar.sql";
// import { polarTable, Standing } from "./polar.sql.ts.test";
import { Polar as PolarSdk } from "@polar-sh/sdk";
import { useTransaction } from "../drizzle/transaction";
@@ -15,10 +15,10 @@ export module Polar {
export const Info = z.object({
teamID: z.string(),
customerID: z.string(),
subscriptionID: z.string().nullable(),
customerID: z.string(),
subscriptionItemID: z.string().nullable(),
standing: z.enum(Standing),
// standing: z.enum(Standing),
});
export type Info = z.infer<typeof Info>;
@@ -53,16 +53,16 @@ export module Polar {
),
};
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 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 {
@@ -81,89 +81,89 @@ export module Polar {
}
})
export const setCustomerID = fn(Info.shape.customerID, async (customerID) =>
useTransaction(async (tx) =>
tx
.insert(polarTable)
.values({
teamID: useTeam(),
customerID,
standing: "new",
})
.execute(),
),
);
// 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 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 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 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)),
),
);
// 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<typeof Info> {
return {
teamID: input.teamID,
customerID: input.customerID,
subscriptionID: input.subscriptionID,
subscriptionItemID: input.subscriptionItemID,
standing: input.standing,
};
}
// 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,
// };
// }
}

View File

@@ -2,6 +2,7 @@ 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(
@@ -16,7 +17,7 @@ export const polarTable = pgTable(
}),
standing: text("standing", { enum: Standing }).notNull(),
},
(table) => ({
(table) => [
...teamIndexes(table),
})
]
)

View File

@@ -2,14 +2,14 @@ import { z } from "zod";
import { Resource } from "sst";
import { bus } from "sst/aws/bus";
import { Common } from "../common";
import { createID, fn } from "../utils";
import { Examples } from "../examples";
import { teamTable } from "./team.sql";
import { createEvent } from "../event";
import { assertActor, withActor } from "../actor";
import { createID, fn } from "../utils";
import { and, eq, sql } from "../drizzle";
import { PlanType, teamTable } from "./team.sql";
import { assertActor, withActor } from "../actor";
import { memberTable } from "../member/member.sql";
import { HTTPException } from 'hono/http-exception';
import { ErrorCodes, VisibleError } from "../error";
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
export module Team {
@@ -19,13 +19,17 @@ export module Team {
description: Common.IdDescription,
example: Examples.Team.id,
}),
slug: z.string().openapi({
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
}),
name: z.string().openapi({
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
})
})
.openapi({
@@ -45,40 +49,36 @@ export module Team {
),
};
export class TeamExistsError extends HTTPException {
export class TeamExistsError extends VisibleError {
constructor(slug: string) {
super(
400,
{ message: `There is already a team named "${slug}"`, }
"already_exists",
ErrorCodes.Validation.TEAM_ALREADY_EXISTS,
`There is already a team named "${slug}"`
);
}
}
export const create = fn(
Info.pick({ slug: true, id: true, name: true }).partial({
Info.pick({ slug: true, id: true, name: true, planType: true }).partial({
id: true,
}), (input) => {
createTransaction(async (tx) => {
const id = input.id ?? createID("team");
const result = await tx.insert(teamTable).values({
id,
slug: input.slug,
name: input.name
})
.onConflictDoNothing({ target: teamTable.slug })
if (!result.rowCount) throw new TeamExistsError(input.slug);
await afterTx(() =>
withActor({ type: "system", properties: { teamID: id } }, () =>
bus.publish(Resource.Bus, Events.Created, {
teamID: id,
})
),
);
return id;
}), (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,
name: input.name
})
.onConflictDoNothing({ target: teamTable.slug })
if (result.count === 0) throw new TeamExistsError(input.slug);
return id;
})
)
export const remove = fn(Info.shape.id, (input) =>
useTransaction(async (tx) => {
@@ -147,6 +147,7 @@ export module Team {
id: input.id,
name: input.name,
slug: input.slug,
planType: input.planType,
};
}

View File

@@ -1,21 +1,27 @@
import {} from "drizzle-orm/postgres-js";
import { } from "drizzle-orm/postgres-js";
import { timestamps, id } from "../drizzle/types";
import {
varchar,
pgTable,
primaryKey,
uniqueIndex,
varchar,
text
} from "drizzle-orm/pg-core";
export const PlanType = ["Hosted", "BYOG"] as const;
export const teamTable = pgTable(
"team",
{
...id,
...timestamps,
slug: varchar("slug", { length: 255 }).notNull(),
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)],
(table) => [
uniqueIndex("slug").on(table.slug)
],
);
export function teamIndexes(table: any) {

View File

@@ -1,8 +1,8 @@
import { z } from "zod";
import { Polar } from "../polar";
import { Team } from "../team";
import { bus } from "sst/aws/bus";
import { Common } from "../common";
import { Polar } from "../polar/index";
import { createID, fn } from "../utils";
import { userTable } from "./user.sql";
import { createEvent } from "../event";
@@ -188,7 +188,7 @@ export module User {
await tx
.update(userTable)
.set({
timeDeleted: sql`CURRENT_TIMESTAMP()`,
timeDeleted: sql`now()`,
})
.where(and(eq(userTable.id, input)))
.execute();

View File

@@ -1,6 +1,6 @@
import { z } from "zod";
import { id, timestamps } from "../drizzle/types";
import { integer, pgTable, text, uniqueIndex, varchar,json } from "drizzle-orm/pg-core";
import { integer, pgTable, text, uniqueIndex, varchar, json } from "drizzle-orm/pg-core";
// Whether this user is part of the Nestri Team, comes with privileges
export const UserFlags = z.object({
@@ -15,13 +15,13 @@ export const userTable = pgTable(
...id,
...timestamps,
avatarUrl: text("avatar_url"),
email: varchar("email", { length: 255 }).notNull(),
name: varchar("name", { length: 255 }).notNull(),
discriminator: integer("discriminator").notNull(),
email: varchar("email", { length: 255 }).notNull(),
polarCustomerID: varchar("polar_customer_id", { length: 255 }).unique(),
flags: json("flags").$type<UserFlags>().default({}),
},
(user) => [
uniqueIndex("user_email").on(user.email),
],
]
);