mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 16:55:37 +02:00
⭐ feat(infra): Update infra and add support for teams to SST (#186)
## Description - [x] Adds support for AWS SSO, which makes us (the team) able to use SST and update the components independently - [x] Splits the webpage into the landing page (Qwik), and Astro (the console) in charge of playing. This allows us to pass in Environment Variables to the console - ~Migrates the docs from Nuxt to Nextjs, and connects them to SST. This allows us to use Fumadocs _citation needed_ that's much more beautiful, and supports OpenApi~ - Cloudflare pages with github integration is not working on our new CF account. So we will have to push the pages deployment manually with Github actions - [x] Moves the current set up from my personal CF and AWS accounts to dedicated Nestri accounts - ## Related Issues <!-- List any related issues (e.g., "Closes #123", "Fixes #456") --> ## Type of Change - [ ] Bug fix (non-breaking change) - [x] New feature (non-breaking change) - [ ] Breaking change (fix or feature that changes existing functionality) - [x] Documentation update - [ ] Other (please describe): ## Checklist - [x] I have updated relevant documentation - [x] My code follows the project's coding style - [x] My changes generate no new warnings/errors ## Notes for Reviewers <!-- Point out areas you'd like reviewers to focus on, questions you have, or decisions that need discussion --> Please approve my PR 🥹 ## Screenshots/Demo <!-- If applicable, add screenshots or a GIF demo of your changes (especially for UI changes) --> ## Additional Context <!-- Add any other context about the pull request here -->
This commit is contained in:
@@ -1,86 +1,92 @@
|
||||
import { createContext } from "./context";
|
||||
import { z } from "zod";
|
||||
import { eq } from "./drizzle";
|
||||
import { VisibleError } from "./error";
|
||||
|
||||
export interface UserActor {
|
||||
type: "user";
|
||||
properties: {
|
||||
accessToken: string;
|
||||
userID: string;
|
||||
auth?:
|
||||
| {
|
||||
type: "personal";
|
||||
token: string;
|
||||
}
|
||||
| {
|
||||
type: "oauth";
|
||||
clientID: string;
|
||||
};
|
||||
};
|
||||
import { createContext } from "./context";
|
||||
import { UserFlags, userTable } from "./user/user.sql";
|
||||
import { useTransaction } from "./drizzle/transaction";
|
||||
|
||||
export const PublicActor = z.object({
|
||||
type: z.literal("public"),
|
||||
properties: z.object({}),
|
||||
});
|
||||
export type PublicActor = z.infer<typeof PublicActor>;
|
||||
|
||||
export const UserActor = z.object({
|
||||
type: z.literal("user"),
|
||||
properties: z.object({
|
||||
userID: z.string(),
|
||||
email: z.string().nonempty(),
|
||||
}),
|
||||
});
|
||||
export type UserActor = z.infer<typeof UserActor>;
|
||||
|
||||
export const MemberActor = z.object({
|
||||
type: z.literal("member"),
|
||||
properties: z.object({
|
||||
memberID: z.string(),
|
||||
teamID: z.string(),
|
||||
}),
|
||||
});
|
||||
export type MemberActor = z.infer<typeof MemberActor>;
|
||||
|
||||
export const SystemActor = z.object({
|
||||
type: z.literal("system"),
|
||||
properties: z.object({
|
||||
teamID: z.string(),
|
||||
}),
|
||||
});
|
||||
export type SystemActor = z.infer<typeof SystemActor>;
|
||||
|
||||
export const Actor = z.discriminatedUnion("type", [
|
||||
MemberActor,
|
||||
UserActor,
|
||||
PublicActor,
|
||||
SystemActor,
|
||||
]);
|
||||
export type Actor = z.infer<typeof Actor>;
|
||||
|
||||
const ActorContext = createContext<Actor>("actor");
|
||||
|
||||
export const useActor = ActorContext.use;
|
||||
export const withActor = ActorContext.with;
|
||||
|
||||
export function useUserID() {
|
||||
const actor = ActorContext.use();
|
||||
if (actor.type === "user") return actor.properties.userID;
|
||||
throw new VisibleError(
|
||||
"unauthorized",
|
||||
`You don't have permission to access this resource`,
|
||||
);
|
||||
}
|
||||
|
||||
export interface DeviceActor {
|
||||
type: "device";
|
||||
properties: {
|
||||
teamSlug: string;
|
||||
hostname: string;
|
||||
auth?:
|
||||
| {
|
||||
type: "personal";
|
||||
token: string;
|
||||
}
|
||||
| {
|
||||
type: "oauth";
|
||||
clientID: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface PublicActor {
|
||||
type: "public";
|
||||
properties: {};
|
||||
}
|
||||
|
||||
type Actor = UserActor | PublicActor | DeviceActor;
|
||||
export const ActorContext = createContext<Actor>();
|
||||
|
||||
export function useCurrentUser() {
|
||||
const actor = ActorContext.use();
|
||||
if (actor.type === "user") return {
|
||||
id:actor.properties.userID,
|
||||
token: actor.properties.accessToken,
|
||||
};
|
||||
|
||||
throw new VisibleError(
|
||||
"auth",
|
||||
"unauthorized",
|
||||
`You don't have permission to access this resource`,
|
||||
);
|
||||
}
|
||||
|
||||
export function useCurrentDevice() {
|
||||
const actor = ActorContext.use();
|
||||
if (actor.type === "device") return {
|
||||
hostname:actor.properties.hostname,
|
||||
teamSlug: actor.properties.teamSlug
|
||||
};
|
||||
throw new VisibleError(
|
||||
"auth",
|
||||
"unauthorized",
|
||||
`You don't have permission to access this resource`,
|
||||
);
|
||||
}
|
||||
|
||||
export function useActor() {
|
||||
try {
|
||||
return ActorContext.use();
|
||||
} catch {
|
||||
return { type: "public", properties: {} } as PublicActor;
|
||||
}
|
||||
export function assertActor<T extends Actor["type"]>(type: T) {
|
||||
const actor = useActor();
|
||||
if (actor.type !== type) {
|
||||
throw new Error(`Expected actor type ${type}, got ${actor.type}`);
|
||||
}
|
||||
|
||||
export function assertActor<T extends Actor["type"]>(type: T) {
|
||||
const actor = useActor();
|
||||
if (actor.type !== type)
|
||||
throw new VisibleError("auth", "actor.invalid", `Actor is not "${type}"`);
|
||||
return actor as Extract<Actor, { type: T }>;
|
||||
}
|
||||
|
||||
return actor as Extract<Actor, { type: T }>;
|
||||
}
|
||||
|
||||
export function useTeam() {
|
||||
const actor = useActor();
|
||||
if ("teamID" in actor.properties) return actor.properties.teamID;
|
||||
throw new Error(`Expected actor to have teamID`);
|
||||
}
|
||||
|
||||
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(
|
||||
"user.flags",
|
||||
"Actor does not have " + flag + " flag",
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { z } from "zod"
|
||||
import { Resource } from "sst";
|
||||
import { doubleFn, fn } from "../utils";
|
||||
import { AwsClient } from "aws4fetch";
|
||||
import { DescribeTasksCommandOutput, StopTaskCommandOutput, type RunTaskCommandOutput } from "@aws-sdk/client-ecs";
|
||||
|
||||
|
||||
export module Aws {
|
||||
export const client = async () => {
|
||||
return new AwsClient({
|
||||
accessKeyId: Resource.AwsAccessKey.value,
|
||||
secretAccessKey: Resource.AwsSecretKey.value,
|
||||
region: "us-east-1",
|
||||
});
|
||||
}
|
||||
|
||||
export const EcsRunTask = fn(z.object({
|
||||
cluster: z.string(),
|
||||
count: z.number(),
|
||||
taskDefinition: z.string(),
|
||||
launchType: z.enum(["EC2", "FARGATE"]),
|
||||
overrides: z.object({
|
||||
containerOverrides: z.object({
|
||||
name: z.string(),
|
||||
environment: z.object({
|
||||
name: z.string(),
|
||||
value: z.string().or(z.number())
|
||||
}).array()
|
||||
}).array()
|
||||
})
|
||||
}), async (body) => {
|
||||
|
||||
const c = await client();
|
||||
|
||||
const url = new URL(`https://ecs.${c.region}.amazonaws.com/`)
|
||||
|
||||
const res = await c.fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Amz-Target": "AmazonEC2ContainerServiceV20141113.RunTask",
|
||||
"Content-Type": "application/x-amz-json-1.1",
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
return await res.json() as RunTaskCommandOutput
|
||||
})
|
||||
|
||||
export const EcsDescribeTasks = fn(z.object({ tasks: z.string().array(), cluster: z.string() }), async (body) => {
|
||||
const c = await client();
|
||||
|
||||
const url = new URL(`https://ecs.${c.region}.amazonaws.com/`)
|
||||
|
||||
const res = await c.fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Amz-Target": "AmazonEC2ContainerServiceV20141113.DescribeTasks",
|
||||
"Content-Type": "application/x-amz-json-1.1",
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
return await res.json() as DescribeTasksCommandOutput
|
||||
})
|
||||
|
||||
|
||||
export const EcsStopTask = fn(z.object({
|
||||
cluster: z.string().optional(),
|
||||
reason: z.string().optional(),
|
||||
task: z.string()
|
||||
}), async (body) => {
|
||||
const c = await client();
|
||||
|
||||
const url = new URL(`https://ecs.${c.region}.amazonaws.com/`)
|
||||
|
||||
const res = await c.fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Amz-Target": "AmazonEC2ContainerServiceV20141113.StopTask",
|
||||
"Content-Type": "application/x-amz-json-1.1",
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
return await res.json() as StopTaskCommandOutput
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
export function createContext<T>() {
|
||||
export function createContext<T>(name: string) {
|
||||
const storage = new AsyncLocalStorage<T>();
|
||||
return {
|
||||
use() {
|
||||
const result = storage.getStore();
|
||||
if (!result) {
|
||||
throw new Error("No context available");
|
||||
throw new Error("Context not provided: " + name);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
with<R>(value: T, fn: () => R) {
|
||||
return storage.run<R>(value, fn);
|
||||
return storage.run<R, any[]>(value, fn);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Resource } from "sst";
|
||||
import { init } from "@instantdb/admin";
|
||||
import schema from "../instant.schema";
|
||||
|
||||
const databaseClient = () => init({
|
||||
appId: Resource.InstantAppId.value,
|
||||
adminToken: Resource.InstantAdminToken.value,
|
||||
schema
|
||||
})
|
||||
|
||||
|
||||
export default databaseClient
|
||||
22
packages/core/src/drizzle/index.ts
Normal file
22
packages/core/src/drizzle/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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";
|
||||
|
||||
neonConfig.webSocketConstructor = ws;
|
||||
|
||||
const client = new Pool({ connectionString: `postgres://${Resource.Database.user}:${Resource.Database.password}@${Resource.Database.host}/${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,
|
||||
});
|
||||
65
packages/core/src/drizzle/transaction.ts
Normal file
65
packages/core/src/drizzle/transaction.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { db } from ".";
|
||||
import {
|
||||
PgTransaction,
|
||||
PgTransactionConfig
|
||||
} from "drizzle-orm/pg-core";
|
||||
import {
|
||||
NeonQueryResultHKT
|
||||
// NeonHttpQueryResultHKT
|
||||
} from "drizzle-orm/neon-serverless";
|
||||
import { ExtractTablesWithRelations } from "drizzle-orm";
|
||||
import { createContext } from "../context";
|
||||
|
||||
export type Transaction = PgTransaction<
|
||||
NeonQueryResultHKT,
|
||||
Record<string, never>,
|
||||
ExtractTablesWithRelations<Record<string, never>>
|
||||
>;
|
||||
|
||||
type TxOrDb = Transaction | typeof db;
|
||||
|
||||
const TransactionContext = createContext<{
|
||||
tx: Transaction;
|
||||
effects: (() => void | Promise<void>)[];
|
||||
}>("TransactionContext");
|
||||
|
||||
export async function useTransaction<T>(callback: (trx: TxOrDb) => Promise<T>) {
|
||||
try {
|
||||
const { tx } = TransactionContext.use();
|
||||
return callback(tx);
|
||||
} catch {
|
||||
return callback(db);
|
||||
}
|
||||
}
|
||||
|
||||
export async function afterTx(effect: () => any | Promise<any>) {
|
||||
try {
|
||||
const { effects } = TransactionContext.use();
|
||||
effects.push(effect);
|
||||
} catch {
|
||||
await effect();
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTransaction<T>(
|
||||
callback: (tx: Transaction) => Promise<T>,
|
||||
isolationLevel?: PgTransactionConfig["isolationLevel"],
|
||||
): Promise<T> {
|
||||
try {
|
||||
const { tx } = TransactionContext.use();
|
||||
return callback(tx);
|
||||
} catch {
|
||||
const effects: (() => void | Promise<void>)[] = [];
|
||||
const result = await db.transaction(
|
||||
async (tx) => {
|
||||
return TransactionContext.with({ tx, effects }, () => callback(tx));
|
||||
},
|
||||
{
|
||||
isolationLevel: isolationLevel || "read committed",
|
||||
},
|
||||
);
|
||||
await Promise.all(effects.map((x) => x()));
|
||||
// await db.$client.end()
|
||||
return result as T;
|
||||
}
|
||||
}
|
||||
30
packages/core/src/drizzle/types.ts
Normal file
30
packages/core/src/drizzle/types.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { char, timestamp as rawTs } from "drizzle-orm/pg-core";
|
||||
|
||||
export const ulid = (name: string) => char(name, { length: 26 + 4 });
|
||||
|
||||
export const id = {
|
||||
get id() {
|
||||
return ulid("id").primaryKey().notNull();
|
||||
},
|
||||
};
|
||||
|
||||
export const teamID = {
|
||||
get id() {
|
||||
return ulid("id").notNull();
|
||||
},
|
||||
get teamID() {
|
||||
return ulid("team_id").notNull();
|
||||
},
|
||||
};
|
||||
|
||||
export const utc = (name: string) =>
|
||||
rawTs(name, {
|
||||
withTimezone: true,
|
||||
// mode: "date"
|
||||
});
|
||||
|
||||
export const timestamps = {
|
||||
timeCreated: utc("time_created").notNull().defaultNow(),
|
||||
timeUpdated: utc("time_updated").notNull().defaultNow(),
|
||||
timeDeleted: utc("time_deleted"),
|
||||
};
|
||||
@@ -1,45 +1,36 @@
|
||||
import { LoopsClient } from "loops";
|
||||
import { Resource } from "sst/resource"
|
||||
import { Resource } from "sst";
|
||||
import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2";
|
||||
|
||||
export namespace Email {
|
||||
export const Client = () => new LoopsClient(Resource.LoopsApiKey.value);
|
||||
export const Client = new SESv2Client({});
|
||||
|
||||
export async function send(
|
||||
to: string,
|
||||
body: string,
|
||||
) {
|
||||
|
||||
try {
|
||||
await Client().sendTransactionalEmail(
|
||||
{
|
||||
transactionalId: "cm58pdf8d03upb5ecirnmvrfb",
|
||||
email: to,
|
||||
dataVariables: {
|
||||
logincode: body
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.log("error sending email", error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendWelcome(
|
||||
to: string,
|
||||
name: string,
|
||||
) {
|
||||
|
||||
try {
|
||||
await Client().sendTransactionalEmail(
|
||||
{
|
||||
transactionalId: "cm61jrbbx02twlstfwfcywt5u",
|
||||
email: to,
|
||||
dataVariables: {
|
||||
name
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.log("error sending email", error)
|
||||
}
|
||||
}
|
||||
export async function send(
|
||||
from: string,
|
||||
to: string,
|
||||
subject: string,
|
||||
body: string,
|
||||
) {
|
||||
from = from + "@" + Resource.Mail.sender;
|
||||
console.log("sending email", subject, from, to);
|
||||
await Client.send(
|
||||
new SendEmailCommand({
|
||||
Destination: {
|
||||
ToAddresses: [to],
|
||||
},
|
||||
Content: {
|
||||
Simple: {
|
||||
Body: {
|
||||
Text: {
|
||||
Data: body,
|
||||
},
|
||||
},
|
||||
Subject: {
|
||||
Data: subject,
|
||||
},
|
||||
},
|
||||
},
|
||||
FromEmailAddress: `Nestri <${from}>`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
export class VisibleError extends Error {
|
||||
constructor(
|
||||
public kind: "input" | "auth",
|
||||
public code: string,
|
||||
public message: string,
|
||||
) {
|
||||
|
||||
23
packages/core/src/event.ts
Normal file
23
packages/core/src/event.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useActor } from "./actor";
|
||||
import { event as sstEvent } from "sst/event";
|
||||
import { ZodValidator } from "sst/event/validator";
|
||||
|
||||
export const createEvent = sstEvent.builder({
|
||||
validator: ZodValidator,
|
||||
metadata() {
|
||||
return {
|
||||
actor: useActor(),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
import { openevent } from "@openauthjs/openevent/event";
|
||||
export { publish } from "@openauthjs/openevent/publisher/drizzle";
|
||||
|
||||
export const event = openevent({
|
||||
metadata() {
|
||||
return {
|
||||
actor: useActor(),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,75 +1,29 @@
|
||||
import { teamID } from "./drizzle/types";
|
||||
import { prefixes } from "./utils";
|
||||
export module Examples {
|
||||
export const Id = (prefix: keyof typeof prefixes) =>
|
||||
`${prefixes[prefix]}_XXXXXXXXXXXXXXXXXXXXXXXXX`;
|
||||
|
||||
export const User = {
|
||||
id: "0bfcc712-df13-4454-81a8-fbee66eddca4",
|
||||
id: Id("user"),
|
||||
name: "John Doe",
|
||||
email: "john@example.com",
|
||||
discriminator: 47,
|
||||
avatarUrl: "https://cdn.discordapp.com/avatars/xxxxxxx/xxxxxxx.png",
|
||||
polarCustomerID: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
};
|
||||
|
||||
export const Task = {
|
||||
id: "0bfcc712-df13-4454-81a8-fbee66eddca4",
|
||||
taskID: "b8302fca2d224d91ab342a2e4ab926d3",
|
||||
type: "AWS" as const, //or "on-premises",
|
||||
lastStatus: "RUNNING" as const,
|
||||
healthStatus: "UNKNOWN" as const,
|
||||
startedAt: '2025-01-09T01:56:23.902Z',
|
||||
lastUpdated: '2025-01-09T01:56:23.902Z',
|
||||
stoppedAt: '2025-01-09T04:46:23.902Z'
|
||||
}
|
||||
|
||||
export const Profile = {
|
||||
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
username: "janedoe47",
|
||||
status: "active" as const,
|
||||
avatarUrl: "https://cdn.discordapp.com/avatars/xxxxxxx/xxxxxxx.png",
|
||||
discriminator: 12, //it needs to be two digits
|
||||
createdAt: '2025-01-04T11:56:23.902Z',
|
||||
updatedAt: '2025-01-09T01:56:23.902Z'
|
||||
}
|
||||
|
||||
export const Subscription = {
|
||||
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
checkoutID: "0bfcb712-df43-4454-81a8-fbee66eddca4",
|
||||
// productID: "0bfcb712-df43-4454-81a8-fbee66eddca4",
|
||||
// quantity: 1,
|
||||
// frequency: "monthly" as const,
|
||||
// next: '2025-01-09T01:56:23.902Z',
|
||||
canceledAt: '2025-02-09T01:56:23.902Z'
|
||||
}
|
||||
|
||||
export const Team = {
|
||||
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
// owner: true,
|
||||
name: "Jane Doe's Games",
|
||||
slug: "jane-does-games",
|
||||
createdAt: '2025-01-04T11:56:23.902Z',
|
||||
updatedAt: '2025-01-09T01:56:23.902Z'
|
||||
id: Id("team"),
|
||||
name: "John Does' Team",
|
||||
slug: "john_doe",
|
||||
}
|
||||
|
||||
export const Member = {
|
||||
id: Id("member"),
|
||||
email: "john@example.com",
|
||||
teamID: Id("team"),
|
||||
timeSeen: new Date("2025-02-23T13:39:52.249Z"),
|
||||
}
|
||||
|
||||
export const Machine = {
|
||||
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
hostname: "DESKTOP-EUO8VSF",
|
||||
fingerprint: "fc27f428f9ca47d4b41b70889ae0c62090",
|
||||
createdAt: '2025-01-04T11:56:23.902Z',
|
||||
deletedAt: '2025-01-09T01:56:23.902Z'
|
||||
}
|
||||
|
||||
export const Instance = {
|
||||
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
hostname: "a955e059f05d",
|
||||
createdAt: '2025-01-04T11:56:23.902Z',
|
||||
lastActive: '2025-01-09T01:56:23.902Z'
|
||||
}
|
||||
|
||||
export const Game = {
|
||||
id: '0bfcb712-df13-4454-81a8-fbee66eddca4',
|
||||
name: "Control Ultimate Edition",
|
||||
steamID: 870780,
|
||||
}
|
||||
|
||||
export const Session = {
|
||||
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
public: true,
|
||||
startedAt: '2025-01-04T11:56:23.902Z',
|
||||
endedAt: '2025-01-04T12:36:23.902Z'
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
// import { z } from "zod"
|
||||
// import { fn } from "../utils";
|
||||
// import { Common } from "../common";
|
||||
// import { Examples } from "../examples";
|
||||
// import databaseClient from "../database"
|
||||
// import { id as createID } from "@instantdb/admin";
|
||||
// import { groupBy, map, pipe, values } from "remeda"
|
||||
// import { useCurrentDevice, useCurrentUser } from "../actor";
|
||||
|
||||
// export module Games {
|
||||
// export const Info = z
|
||||
// .object({
|
||||
// id: z.string().openapi({
|
||||
// description: Common.IdDescription,
|
||||
// example: Examples.Game.id,
|
||||
// }),
|
||||
// name: z.string().openapi({
|
||||
// description: "A human-readable name for the game, used for easy identification.",
|
||||
// example: Examples.Game.name,
|
||||
// }),
|
||||
// steamID: z.number().openapi({
|
||||
// description: "The Steam ID of the game, used to identify it during installation and runtime.",
|
||||
// example: Examples.Game.steamID,
|
||||
// })
|
||||
// })
|
||||
// .openapi({
|
||||
// ref: "Game",
|
||||
// description: "Represents a Steam game that can be installed and played on a machine.",
|
||||
// example: Examples.Game,
|
||||
// });
|
||||
|
||||
// export type Info = z.infer<typeof Info>;
|
||||
|
||||
// export const create = fn(Info.pick({ name: true, steamID: true }), async (input) => {
|
||||
// const id = createID()
|
||||
// const db = databaseClient()
|
||||
// const device = useCurrentDevice()
|
||||
|
||||
// await db.transact(
|
||||
// db.tx.games[id]!.update({
|
||||
// name: input.name,
|
||||
// steamID: input.steamID,
|
||||
// }).link({ machines: device.id })
|
||||
// )
|
||||
// //
|
||||
// return id
|
||||
// })
|
||||
|
||||
// export const list = async () => {
|
||||
// const db = databaseClient()
|
||||
// const user = useCurrentUser()
|
||||
|
||||
// const query = {
|
||||
// $users: {
|
||||
// $: { where: { id: user.id } },
|
||||
// games: {}
|
||||
// },
|
||||
// }
|
||||
|
||||
// const res = await db.query(query)
|
||||
|
||||
// const games = res.$users[0]?.games
|
||||
// if (games && games.length > 0) {
|
||||
// const result = pipe(
|
||||
// games,
|
||||
// groupBy(x => x.id),
|
||||
// values(),
|
||||
// map((group): Info => ({
|
||||
// id: group[0].id,
|
||||
// name: group[0].name,
|
||||
// steamID: group[0].steamID,
|
||||
// }))
|
||||
// )
|
||||
// return result
|
||||
// }
|
||||
// return null
|
||||
// }
|
||||
|
||||
// export const fromSteamID = fn(z.number(), async (steamID) => {
|
||||
// const db = databaseClient()
|
||||
|
||||
// const query = {
|
||||
// games: {
|
||||
// $: {
|
||||
// where: {
|
||||
// steamID,
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// const res = await db.query(query)
|
||||
|
||||
// const games = res.games
|
||||
|
||||
// if (games.length > 0) {
|
||||
// const result = pipe(
|
||||
// games,
|
||||
// groupBy(x => x.id),
|
||||
// values(),
|
||||
// map((group): Info => ({
|
||||
// id: group[0].id,
|
||||
// name: group[0].name,
|
||||
// steamID: group[0].steamID,
|
||||
// }))
|
||||
// )
|
||||
// return result[0]
|
||||
// }
|
||||
|
||||
// return null
|
||||
// })
|
||||
|
||||
// export const linkToCurrentUser = fn(z.string(), async (steamID) => {
|
||||
// const user = useCurrentUser()
|
||||
// const db = databaseClient()
|
||||
|
||||
// await db.transact(db.tx.games[steamID]!.link({ owners: user.id }))
|
||||
|
||||
// return "ok"
|
||||
// })
|
||||
|
||||
// export const unLinkFromCurrentUser = fn(z.number(), async (steamID) => {
|
||||
// const user = useCurrentUser()
|
||||
// const db = databaseClient()
|
||||
|
||||
// const query = {
|
||||
// $users: {
|
||||
// $: { where: { id: user.id } },
|
||||
// games: {
|
||||
// $: {
|
||||
// where: {
|
||||
// steamID,
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
// }
|
||||
|
||||
// const res = await db.query(query)
|
||||
// const games = res.$users[0]?.games
|
||||
// if (games && games.length > 0) {
|
||||
// const game = games[0] as Info
|
||||
// await db.transact(db.tx.games[game.id]!.unlink({ owners: user.id }))
|
||||
|
||||
// return "ok"
|
||||
// }
|
||||
|
||||
// return null
|
||||
// })
|
||||
|
||||
// }
|
||||
@@ -1,83 +0,0 @@
|
||||
import { z } from "zod"
|
||||
import { fn } from "../utils";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import databaseClient from "../database"
|
||||
import { id as createID } from "@instantdb/admin";
|
||||
import { groupBy, map, pipe, values } from "remeda"
|
||||
|
||||
export module Instances {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Instance.id,
|
||||
}),
|
||||
hostname: z.string().openapi({
|
||||
description: "The container's hostname",
|
||||
example: Examples.Instance.hostname,
|
||||
}),
|
||||
createdAt: z.string().or(z.number()).openapi({
|
||||
description: "The time this instances was registered on the network",
|
||||
example: Examples.Instance.createdAt,
|
||||
}),
|
||||
lastActive: z.string().or(z.number()).optional().openapi({
|
||||
description: "The time this instance was last seen on the network",
|
||||
example: Examples.Instance.lastActive,
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Instance",
|
||||
description: "Represents a running container that is connected to the Nestri network..",
|
||||
example: Examples.Instance,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
export const create = fn(z.object({ hostname: z.string(), teamID: z.string() }), async (input) => {
|
||||
const id = createID()
|
||||
const now = new Date().toISOString()
|
||||
const db = databaseClient()
|
||||
await db.transact(
|
||||
db.tx.instances[id]!.update({
|
||||
hostname: input.hostname,
|
||||
createdAt: now,
|
||||
}).link({ owners: input.teamID })
|
||||
)
|
||||
|
||||
return "ok"
|
||||
})
|
||||
|
||||
export const fromTeamID = fn(z.string(), async (teamID) => {
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
instances: {
|
||||
$: {
|
||||
where: {
|
||||
owners: teamID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
const data = res.instances
|
||||
|
||||
if (data && data.length > 0) {
|
||||
const result = pipe(
|
||||
data,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
lastActive: group[0].lastActive,
|
||||
hostname: group[0].hostname,
|
||||
createdAt: group[0].createdAt
|
||||
}))
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
// import { z } from "zod"
|
||||
// import { fn } from "../utils";
|
||||
// import { Games } from "../game"
|
||||
// import { Common } from "../common";
|
||||
// import { Examples } from "../examples";
|
||||
// import { useCurrentUser } from "../actor";
|
||||
// import databaseClient from "../database"
|
||||
// import { id as createID } from "@instantdb/admin";
|
||||
// import { groupBy, map, pipe, values } from "remeda"
|
||||
// export module Machines {
|
||||
// export const Info = z
|
||||
// .object({
|
||||
// id: z.string().openapi({
|
||||
// description: Common.IdDescription,
|
||||
// example: Examples.Machine.id,
|
||||
// }),
|
||||
// hostname: z.string().openapi({
|
||||
// description: "The Linux hostname that identifies this machine",
|
||||
// example: Examples.Machine.hostname,
|
||||
// }),
|
||||
// fingerprint: z.string().openapi({
|
||||
// description: "A unique identifier derived from the machine's Linux machine ID.",
|
||||
// example: Examples.Machine.fingerprint,
|
||||
// }),
|
||||
// createdAt: z.string().or(z.number()).openapi({
|
||||
// description: "Represents a machine running on the Nestri network, containing its identifying information and metadata.",
|
||||
// example: Examples.Machine.createdAt,
|
||||
// })
|
||||
// })
|
||||
// .openapi({
|
||||
// ref: "Machine",
|
||||
// description: "Represents a physical or virtual machine connected to the Nestri network..",
|
||||
// example: Examples.Machine,
|
||||
// });
|
||||
|
||||
// export type Info = z.infer<typeof Info>;
|
||||
|
||||
// export const create = fn(Info.pick({ fingerprint: true, hostname: true }), async (input) => {
|
||||
// const id = createID()
|
||||
// const now = new Date().toISOString()
|
||||
// const db = databaseClient()
|
||||
// await db.transact(
|
||||
// db.tx.machines[id]!.update({
|
||||
// fingerprint: input.fingerprint,
|
||||
// hostname: input.hostname,
|
||||
// createdAt: now,
|
||||
// //Just in case it had been previously deleted
|
||||
// deletedAt: undefined
|
||||
// })
|
||||
// )
|
||||
|
||||
// return id
|
||||
// })
|
||||
|
||||
// // export const fromID = fn(z.string(), async (id) => {
|
||||
// const db = databaseClient()
|
||||
|
||||
// const query = {
|
||||
// machines: {
|
||||
// $: {
|
||||
// where: {
|
||||
// id: id,
|
||||
// deletedAt: { $isNull: true }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// const res = await db.query(query)
|
||||
// const machines = res.machines
|
||||
|
||||
// if (machines && machines.length > 0) {
|
||||
// const result = pipe(
|
||||
// machines,
|
||||
// groupBy(x => x.id),
|
||||
// values(),
|
||||
// map((group): Info => ({
|
||||
// id: group[0].id,
|
||||
// fingerprint: group[0].fingerprint,
|
||||
// hostname: group[0].hostname,
|
||||
// createdAt: group[0].createdAt
|
||||
// }))
|
||||
// )
|
||||
// return result
|
||||
// }
|
||||
|
||||
// return null
|
||||
// })
|
||||
|
||||
// export const installedGames = fn(z.string(), async (id) => {
|
||||
// const db = databaseClient()
|
||||
|
||||
// const query = {
|
||||
// machines: {
|
||||
// $: {
|
||||
// where: {
|
||||
// id: id,
|
||||
// deletedAt: { $isNull: true }
|
||||
// }
|
||||
// },
|
||||
// games: {}
|
||||
// }
|
||||
// }
|
||||
|
||||
// const res = await db.query(query)
|
||||
// const machines = res.machines
|
||||
|
||||
// if (machines && machines.length > 0) {
|
||||
// const games = machines[0]?.games as any
|
||||
// if (games.length > 0) {
|
||||
// return games as Games.Info[]
|
||||
// }
|
||||
// return null
|
||||
// }
|
||||
|
||||
// return null
|
||||
// })
|
||||
|
||||
// export const fromFingerprint = fn(z.string(), async (input) => {
|
||||
// const db = databaseClient()
|
||||
|
||||
// const query = {
|
||||
// machines: {
|
||||
// $: {
|
||||
// where: {
|
||||
// fingerprint: input,
|
||||
// deletedAt: { $isNull: true }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// const res = await db.query(query)
|
||||
|
||||
// const machines = res.machines
|
||||
|
||||
// if (machines.length > 0) {
|
||||
// const result = pipe(
|
||||
// machines,
|
||||
// groupBy(x => x.id),
|
||||
// values(),
|
||||
// map((group): Info => ({
|
||||
// id: group[0].id,
|
||||
// fingerprint: group[0].fingerprint,
|
||||
// hostname: group[0].hostname,
|
||||
// createdAt: group[0].createdAt
|
||||
// }))
|
||||
// )
|
||||
// return result[0]
|
||||
// }
|
||||
|
||||
// return null
|
||||
// })
|
||||
|
||||
// export const list = async () => {
|
||||
// const user = useCurrentUser()
|
||||
// const db = databaseClient()
|
||||
|
||||
// const query = {
|
||||
// $users: {
|
||||
// $: { where: { id: user.id } },
|
||||
// machines: {
|
||||
// $: {
|
||||
// where: {
|
||||
// deletedAt: { $isNull: true }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
// }
|
||||
|
||||
// const res = await db.query(query)
|
||||
|
||||
// const machines = res.$users[0]?.machines
|
||||
// if (machines && machines.length > 0) {
|
||||
// const result = pipe(
|
||||
// machines,
|
||||
// groupBy(x => x.id),
|
||||
// values(),
|
||||
// map((group): Info => ({
|
||||
// id: group[0].id,
|
||||
// fingerprint: group[0].fingerprint,
|
||||
// hostname: group[0].hostname,
|
||||
// createdAt: group[0].createdAt
|
||||
// }))
|
||||
// )
|
||||
// return result
|
||||
// }
|
||||
// return null
|
||||
// }
|
||||
|
||||
// export const linkToCurrentUser = fn(z.string(), async (id) => {
|
||||
// const user = useCurrentUser()
|
||||
// const db = databaseClient()
|
||||
|
||||
// await db.transact(db.tx.machines[id]!.link({ owner: user.id }))
|
||||
|
||||
// return "ok"
|
||||
// })
|
||||
|
||||
// export const unLinkFromCurrentUser = fn(z.string(), async (id) => {
|
||||
// const user = useCurrentUser()
|
||||
// const db = databaseClient()
|
||||
// const now = new Date().toISOString()
|
||||
|
||||
// const query = {
|
||||
// $users: {
|
||||
// $: { where: { id: user.id } },
|
||||
// machines: {
|
||||
// $: {
|
||||
// where: {
|
||||
// id,
|
||||
// deletedAt: { $isNull: true }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
// }
|
||||
|
||||
// const res = await db.query(query)
|
||||
// const machines = res.$users[0]?.machines
|
||||
// if (machines && machines.length > 0) {
|
||||
// const machine = machines[0] as Info
|
||||
// await db.transact(db.tx.machines[machine.id]!.update({ deletedAt: now }))
|
||||
|
||||
// return "ok"
|
||||
// }
|
||||
|
||||
// return null
|
||||
// })
|
||||
|
||||
// }
|
||||
133
packages/core/src/member/index.ts
Normal file
133
packages/core/src/member/index.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { z } from "zod";
|
||||
import { Resource } from "sst";
|
||||
import { bus } from "sst/aws/bus";
|
||||
import { useTeam } from "../actor";
|
||||
import { Common } from "../common";
|
||||
import { createID, fn } from "../utils";
|
||||
import { createEvent } from "../event";
|
||||
import { Examples } from "../examples";
|
||||
import { memberTable } from "./member.sql";
|
||||
import { and, eq, sql, asc, isNull } from "../drizzle";
|
||||
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export module Member {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Member.id,
|
||||
}),
|
||||
timeSeen: z.date().or(z.null()).openapi({
|
||||
description: "The last time this team member was active",
|
||||
example: Examples.Member.timeSeen
|
||||
}),
|
||||
teamID: z.string().openapi({
|
||||
description: "The unique id of the team this member is on",
|
||||
example: Examples.Member.teamID
|
||||
}),
|
||||
email: z.string().openapi({
|
||||
description: "The email of this team member",
|
||||
example: Examples.Member.email
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Member",
|
||||
description: "Represents a team member on Nestri",
|
||||
example: Examples.Member,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const Events = {
|
||||
Created: createEvent(
|
||||
"member.created",
|
||||
z.object({
|
||||
memberID: Info.shape.id,
|
||||
}),
|
||||
),
|
||||
Updated: createEvent(
|
||||
"member.updated",
|
||||
z.object({
|
||||
memberID: Info.shape.id,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
export const create = fn(
|
||||
Info.pick({ email: true, id: true })
|
||||
.partial({
|
||||
id: true,
|
||||
})
|
||||
.extend({
|
||||
first: z.boolean().optional(),
|
||||
}),
|
||||
(input) =>
|
||||
createTransaction(async (tx) => {
|
||||
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,
|
||||
}
|
||||
})
|
||||
await afterTx(() =>
|
||||
async () => bus.publish(Resource.Bus, Events.Created, { memberID: id }),
|
||||
);
|
||||
return id;
|
||||
}),
|
||||
);
|
||||
|
||||
export const remove = fn(Info.shape.id, (input) =>
|
||||
useTransaction(async (tx) => {
|
||||
await tx
|
||||
.update(memberTable)
|
||||
.set({
|
||||
timeDeleted: sql`CURRENT_TIMESTAMP()`,
|
||||
})
|
||||
.where(and(eq(memberTable.id, input), eq(memberTable.teamID, useTeam())))
|
||||
.execute();
|
||||
return input;
|
||||
}),
|
||||
);
|
||||
|
||||
export const fromEmail = fn(z.string(), async (email) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(memberTable)
|
||||
.where(and(eq(memberTable.email, email), isNull(memberTable.timeDeleted)))
|
||||
.orderBy(asc(memberTable.timeCreated))
|
||||
.then((rows) => rows.map(serialize))
|
||||
.then((rows) => rows.at(0))
|
||||
),
|
||||
)
|
||||
|
||||
export const fromID = fn(z.string(), async (id) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(memberTable)
|
||||
.where(and(eq(memberTable.id, id), isNull(memberTable.timeDeleted)))
|
||||
.orderBy(asc(memberTable.timeCreated))
|
||||
.then((rows) => rows.map(serialize))
|
||||
.then((rows) => rows.at(0))
|
||||
),
|
||||
)
|
||||
|
||||
export function serialize(
|
||||
input: typeof memberTable.$inferSelect,
|
||||
): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
email: input.email,
|
||||
teamID: input.teamID,
|
||||
timeSeen: input.timeSeen
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
18
packages/core/src/member/member.sql.ts
Normal file
18
packages/core/src/member/member.sql.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { teamIndexes } from "../team/team.sql";
|
||||
import { timestamps, utc, teamID } from "../drizzle/types";
|
||||
import { index, pgTable, uniqueIndex, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const memberTable = pgTable(
|
||||
"member",
|
||||
{
|
||||
...teamID,
|
||||
...timestamps,
|
||||
timeSeen: utc("time_seen"),
|
||||
email: varchar("email", { length: 255 }).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
...teamIndexes(table),
|
||||
uniqueIndex("member_email").on(table.teamID, table.email),
|
||||
index("email_global").on(table.email),
|
||||
],
|
||||
);
|
||||
8
packages/core/src/polar.ts
Normal file
8
packages/core/src/polar.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,412 +0,0 @@
|
||||
import { z } from "zod"
|
||||
import { fn } from "../utils";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import databaseClient from "../database";
|
||||
import { groupBy, map, pipe, values } from "remeda"
|
||||
import { id as createID, } from "@instantdb/admin";
|
||||
import { useCurrentUser } from "../actor";
|
||||
|
||||
export const userStatus = z.enum([
|
||||
"active", //online and playing a game
|
||||
"idle", //online and not playing
|
||||
"offline",
|
||||
]);
|
||||
|
||||
export module Profiles {
|
||||
const MAX_ATTEMPTS = 50;
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Machine.id,
|
||||
}),
|
||||
username: z.string().openapi({
|
||||
description: "The user's unique username",
|
||||
example: Examples.Profile.username,
|
||||
}),
|
||||
avatarUrl: z.string().or(z.undefined()).openapi({
|
||||
description: "The url to the profile picture.",
|
||||
example: Examples.Profile.username,
|
||||
}),
|
||||
status: userStatus.openapi({
|
||||
description: "Whether the user is active, idle or offline",
|
||||
example: Examples.Profile.status
|
||||
}),
|
||||
discriminator: z.string().or(z.number()).openapi({
|
||||
description: "The number discriminator for each username",
|
||||
example: Examples.Profile.discriminator,
|
||||
}),
|
||||
createdAt: z.string().or(z.number()).openapi({
|
||||
description: "The time when this profile was first created",
|
||||
example: Examples.Profile.createdAt,
|
||||
}),
|
||||
updatedAt: z.string().or(z.number()).openapi({
|
||||
description: "The time when this profile was last edited",
|
||||
example: Examples.Profile.updatedAt,
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Profile",
|
||||
description: "Represents a profile of a user on Nestri",
|
||||
example: Examples.Profile,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
export type userStatus = z.infer<typeof userStatus>;
|
||||
|
||||
export const sanitizeUsername = (username: string): string => {
|
||||
// Remove spaces and numbers
|
||||
return username.replace(/[\s0-9]/g, '');
|
||||
};
|
||||
|
||||
export const generateDiscriminator = (): string => {
|
||||
return Math.floor(Math.random() * 100).toString().padStart(2, '0');
|
||||
};
|
||||
|
||||
export const isValidDiscriminator = (discriminator: string): boolean => {
|
||||
return /^\d{2}$/.test(discriminator);
|
||||
};
|
||||
|
||||
export const fromUsername = fn(z.string(), async (input) => {
|
||||
const sanitizedUsername = sanitizeUsername(input);
|
||||
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
profiles: {
|
||||
$: {
|
||||
where: {
|
||||
username: sanitizedUsername,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
const profiles = res.profiles
|
||||
|
||||
if (!profiles || profiles.length == 0) {
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return pipe(
|
||||
profiles,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
username: group[0].username,
|
||||
createdAt: group[0].createdAt,
|
||||
discriminator: group[0].discriminator,
|
||||
updatedAt: group[0].updatedAt,
|
||||
status: group[0].status as userStatus
|
||||
}))
|
||||
)
|
||||
})
|
||||
|
||||
export const findAvailableDiscriminator = fn(z.string(), async (input) => {
|
||||
const db = databaseClient()
|
||||
const username = sanitizeUsername(input);
|
||||
|
||||
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
||||
const discriminator = generateDiscriminator();
|
||||
const query = {
|
||||
profiles: {
|
||||
$: {
|
||||
where: {
|
||||
username,
|
||||
discriminator
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const res = await db.query(query)
|
||||
const profiles = res.profiles
|
||||
if (profiles.length === 0) {
|
||||
return discriminator;
|
||||
}
|
||||
}
|
||||
return null; // No available discriminators
|
||||
|
||||
})
|
||||
|
||||
export const create = fn(z.object({ username: z.string(), customDiscriminator: z.string().optional(), avatarUrl: z.string().optional(), owner: z.string() }), async (input) => {
|
||||
const username = sanitizeUsername(input.username);
|
||||
|
||||
const db = databaseClient()
|
||||
const id = createID()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
let discriminator: string | null;
|
||||
if (input.customDiscriminator) {
|
||||
if (!isValidDiscriminator(input.customDiscriminator)) {
|
||||
console.error('Invalid discriminator format')
|
||||
return null
|
||||
// throw new Error('Invalid discriminator format');
|
||||
}
|
||||
|
||||
const query = {
|
||||
profiles: {
|
||||
$: {
|
||||
where: {
|
||||
username,
|
||||
discriminator: input.customDiscriminator
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
const profiles = res.profiles
|
||||
if (profiles.length != 0) {
|
||||
console.error("Username and discriminator combination already taken ")
|
||||
return null
|
||||
// throw new Error('Username and discriminator combination already taken');
|
||||
}
|
||||
|
||||
discriminator = input.customDiscriminator
|
||||
} else {
|
||||
// Generate a random available discriminator
|
||||
discriminator = await findAvailableDiscriminator(username);
|
||||
|
||||
if (!discriminator) {
|
||||
console.error("No available discriminators for this username ")
|
||||
return null
|
||||
// throw new Error('No available discriminators for this username');
|
||||
}
|
||||
}
|
||||
|
||||
return await db.transact(
|
||||
db.tx.profiles[id]!.update({
|
||||
username,
|
||||
avatarUrl: input.avatarUrl,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
discriminator,
|
||||
status: "idle"
|
||||
}).link({ owner: input.owner })
|
||||
)
|
||||
})
|
||||
|
||||
export const getFullUsername = async (username: string) => {
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
profiles: {
|
||||
$: {
|
||||
where: {
|
||||
username,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const res = await db.query(query)
|
||||
const profiles = res.profiles
|
||||
|
||||
if (!profiles || profiles.length === 0) {
|
||||
console.error('User not found')
|
||||
return null
|
||||
// throw new Error('User not found');
|
||||
}
|
||||
|
||||
return `${profiles[0]?.username}#${profiles[0]?.discriminator}`;
|
||||
}
|
||||
|
||||
export const fromOwnerID = async (ownerID: string) => {
|
||||
try {
|
||||
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
profiles: {
|
||||
$: {
|
||||
where: {
|
||||
owner: ownerID
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
const res = await db.query(query)
|
||||
|
||||
const profiles = res.profiles
|
||||
|
||||
if (!profiles || profiles.length === 0) {
|
||||
throw new Error("No profiles were found");
|
||||
}
|
||||
|
||||
const profile = pipe(
|
||||
profiles,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
username: group[0].username,
|
||||
createdAt: group[0].createdAt,
|
||||
updatedAt: group[0].updatedAt,
|
||||
avatarUrl: group[0].avatarUrl,
|
||||
discriminator: group[0].discriminator,
|
||||
status: group[0].status as userStatus
|
||||
}))
|
||||
)
|
||||
|
||||
return profile[0]
|
||||
} catch (error) {
|
||||
console.log("user fromOwnerID", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const fromID = async (id: string) => {
|
||||
try {
|
||||
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
profiles: {
|
||||
$: {
|
||||
where: {
|
||||
id
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
const res = await db.query(query)
|
||||
|
||||
const profiles = res.profiles
|
||||
|
||||
if (!profiles || profiles.length === 0) {
|
||||
throw new Error("No profiles were found");
|
||||
}
|
||||
|
||||
const profile = pipe(
|
||||
profiles,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
username: group[0].username,
|
||||
createdAt: group[0].createdAt,
|
||||
updatedAt: group[0].updatedAt,
|
||||
avatarUrl: group[0].avatarUrl,
|
||||
discriminator: group[0].discriminator,
|
||||
status: group[0].status as userStatus
|
||||
}))
|
||||
)
|
||||
|
||||
return profile[0]
|
||||
} catch (error) {
|
||||
console.log("user fromID", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const fromIDToOwner = async (id: string) => {
|
||||
try {
|
||||
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
profiles: {
|
||||
$: {
|
||||
where: {
|
||||
id
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
const res = await db.query(query)
|
||||
|
||||
const profiles = res.profiles as any
|
||||
|
||||
if (!profiles || profiles.length === 0) {
|
||||
throw new Error("No profiles were found");
|
||||
}
|
||||
|
||||
return profiles[0]!.owner as string
|
||||
} catch (error) {
|
||||
console.log("user fromID", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
export const getCurrentProfile = async () => {
|
||||
const user = useCurrentUser()
|
||||
const currentProfile = await fromOwnerID(user.id);
|
||||
|
||||
return currentProfile
|
||||
}
|
||||
|
||||
export const setStatus = fn(userStatus, async (status) => {
|
||||
try {
|
||||
const user = useCurrentUser()
|
||||
const db = databaseClient()
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.transact(
|
||||
db.tx.profiles[user.id]!.update({
|
||||
status,
|
||||
updatedAt: now
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
console.log("user setStatus error", error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
export const list = async () => {
|
||||
try {
|
||||
const db = databaseClient()
|
||||
// const ago = new Date(Date.now() - (60 * 1000 * 30)).toISOString()
|
||||
const ago = new Date(Date.now() - (24 * 60 * 60 * 1000)).toISOString()
|
||||
|
||||
const query = {
|
||||
profiles: {
|
||||
$: {
|
||||
limit: 10,
|
||||
where: {
|
||||
updatedAt: { $gt: ago },
|
||||
},
|
||||
order: {
|
||||
updatedAt: "desc" as const,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
const profiles = res.profiles
|
||||
|
||||
if (!profiles || profiles.length === 0) {
|
||||
throw new Error("No profiles were found");
|
||||
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
profiles,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
username: group[0].username,
|
||||
createdAt: group[0].createdAt,
|
||||
updatedAt: group[0].updatedAt,
|
||||
avatarUrl: group[0].avatarUrl,
|
||||
discriminator: group[0].discriminator,
|
||||
status: group[0].status as userStatus
|
||||
}))
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
} catch (error) {
|
||||
console.log("user list error", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
import { z } from "zod"
|
||||
import { fn } from "../utils";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import databaseClient from "../database"
|
||||
import { useCurrentUser } from "../actor";
|
||||
import { groupBy, map, pipe, values } from "remeda"
|
||||
import { id as createID } from "@instantdb/admin";
|
||||
|
||||
export module Sessions {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Session.id,
|
||||
}),
|
||||
public: z.boolean().openapi({
|
||||
description: "If true, the session is publicly viewable by all users. If false, only authorized users can access it",
|
||||
example: Examples.Session.public,
|
||||
}),
|
||||
endedAt: z.string().or(z.number()).or(z.undefined()).openapi({
|
||||
description: "The timestamp indicating when this session was completed or terminated. Null if session is still active.",
|
||||
example: Examples.Session.endedAt,
|
||||
}),
|
||||
startedAt: z.string().or(z.number()).openapi({
|
||||
description: "The timestamp indicating when this session started.",
|
||||
example: Examples.Session.startedAt,
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Session",
|
||||
description: "Represents a single game play session, tracking its lifetime and accessibility settings.",
|
||||
example: Examples.Session,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const create = fn(z.object({ public: z.boolean() }), async (input) => {
|
||||
try {
|
||||
const id = createID()
|
||||
const db = databaseClient()
|
||||
const user = useCurrentUser()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.transact(
|
||||
db.tx.sessions[id]!.update({
|
||||
public: input.public,
|
||||
startedAt: now,
|
||||
}).link({ owner: user.id })
|
||||
)
|
||||
|
||||
return id
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
export const getActive = async () => {
|
||||
try {
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
sessions: {
|
||||
$: {
|
||||
where: {
|
||||
endedAt: { $isNull: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
const sessions = res.sessions
|
||||
if (!sessions || sessions.length === 0) {
|
||||
throw new Error("No active sessions found")
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
sessions,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
endedAt: group[0].endedAt,
|
||||
startedAt: group[0].startedAt,
|
||||
public: group[0].public,
|
||||
}))
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const fromID = fn(z.string(), async (id) => {
|
||||
try {
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
sessions: {
|
||||
$: {
|
||||
where: {
|
||||
id: id,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
const sessions = res.sessions
|
||||
|
||||
if (!sessions || sessions.length === 0) {
|
||||
throw new Error("No sessions were found");
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
sessions,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
endedAt: group[0].endedAt,
|
||||
startedAt: group[0].startedAt,
|
||||
public: group[0].public,
|
||||
}))
|
||||
)
|
||||
return result
|
||||
} catch (err) {
|
||||
console.log("sessions error", err)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
export const fromTaskID = fn(z.string(), async (taskID) => {
|
||||
try {
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
sessions: {
|
||||
$: {
|
||||
where: {
|
||||
task: taskID,
|
||||
endedAt: { $isNull: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
const sessions = res.sessions
|
||||
|
||||
if (!sessions || sessions.length === 0) {
|
||||
throw new Error("No sessions were found");
|
||||
}
|
||||
console.log("sessions", sessions)
|
||||
|
||||
const result = pipe(
|
||||
sessions,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
endedAt: group[0].endedAt,
|
||||
startedAt: group[0].startedAt,
|
||||
public: group[0].public,
|
||||
}))
|
||||
)
|
||||
return result[0]
|
||||
} catch (err) {
|
||||
console.log("sessions error", err)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
export const end = fn(z.string(), async (id) => {
|
||||
const user = useCurrentUser()
|
||||
try {
|
||||
const db = databaseClient()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const query = {
|
||||
sessions: {
|
||||
$: {
|
||||
where: {
|
||||
owner: user.id,
|
||||
id,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
const sessions = res.sessions
|
||||
if (!sessions || sessions.length === 0) {
|
||||
throw new Error("No sessions were found");
|
||||
}
|
||||
|
||||
await db.transact(db.tx.sessions[sessions[0]!.id]!.update({ endedAt: now }))
|
||||
|
||||
return "ok"
|
||||
|
||||
} catch (error) {
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
export const fromOwnerID = fn(z.string(), async (id) => {
|
||||
try {
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
sessions: {
|
||||
$: {
|
||||
where: {
|
||||
owner: id,
|
||||
endedAt: { $isNull: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
const sessions = res.sessions
|
||||
|
||||
if (!sessions || sessions.length === 0) {
|
||||
throw new Error("No sessions were found");
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
sessions,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
endedAt: group[0].endedAt,
|
||||
startedAt: group[0].startedAt,
|
||||
public: group[0].public,
|
||||
}))
|
||||
)
|
||||
return result[0]
|
||||
} catch (err) {
|
||||
console.log("session owner error", err)
|
||||
return null
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import databaseClient from "../database"
|
||||
import { fn } from "../utils";
|
||||
import { groupBy, map, pipe, values } from "remeda"
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import { useCurrentUser } from "../actor";
|
||||
import { id as createID } from "@instantdb/admin";
|
||||
import { Email } from "../email";
|
||||
import { Profiles } from "../profile";
|
||||
|
||||
export const SubscriptionFrequency = z.enum([
|
||||
"fixed",
|
||||
"daily",
|
||||
"weekly",
|
||||
"monthly",
|
||||
"yearly",
|
||||
]);
|
||||
|
||||
export type SubscriptionFrequency = z.infer<typeof SubscriptionFrequency>;
|
||||
|
||||
export namespace Subscriptions {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Subscription.id,
|
||||
}),
|
||||
checkoutID: z.string().openapi({
|
||||
description: "The polar.sh checkout id",
|
||||
example: Examples.Subscription.checkoutID,
|
||||
}),
|
||||
// productID: z.string().openapi({
|
||||
// description: "ID of the product being subscribed to.",
|
||||
// example: Examples.Subscription.productID,
|
||||
// }),
|
||||
// quantity: z.number().int().openapi({
|
||||
// description: "Quantity of the subscription.",
|
||||
// example: Examples.Subscription.quantity,
|
||||
// }),
|
||||
// frequency: SubscriptionFrequency.openapi({
|
||||
// description: "Frequency of the subscription.",
|
||||
// example: Examples.Subscription.frequency,
|
||||
// }),
|
||||
// next: z.string().or(z.number()).openapi({
|
||||
// description: "Next billing date for the subscription.",
|
||||
// example: Examples.Subscription.next,
|
||||
// }),
|
||||
canceledAt: z.string().or(z.number()).optional().openapi({
|
||||
description: "Cancelled date for the subscription.",
|
||||
example: Examples.Subscription.canceledAt,
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Subscription",
|
||||
description: "Subscription to a Nestri product.",
|
||||
example: Examples.Subscription,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const list = fn(z.string().optional(), async (userID) => {
|
||||
const db = databaseClient()
|
||||
const user = userID ? userID : useCurrentUser().id
|
||||
|
||||
const query = {
|
||||
subscriptions: {
|
||||
$: {
|
||||
where: {
|
||||
owner: user,
|
||||
canceledAt: { $isNull: true }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
const response = res.subscriptions
|
||||
if (!response || response.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
response,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
// next: group[0].next,
|
||||
// frequency: group[0].frequency as any,
|
||||
// quantity: group[0].quantity,
|
||||
// productID: group[0].productID,
|
||||
checkoutID: group[0].checkoutID,
|
||||
}))
|
||||
)
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
export const create = fn(Info.omit({ id: true, canceledAt: true }), async (input) => {
|
||||
// const id = createID()
|
||||
const id = createID()
|
||||
const db = databaseClient()
|
||||
const user = useCurrentUser()
|
||||
|
||||
//Use the polar.sh ID
|
||||
await db.transact(db.tx.subscriptions[id]!.update({
|
||||
// next: input.next,
|
||||
// frequency: input.frequency,
|
||||
// quantity: input.quantity,
|
||||
checkoutID: input.checkoutID,
|
||||
}).link({ owner: user.id }))
|
||||
const res = await db.auth.getUser({ id: user.id })
|
||||
const profile = await Profiles.fromOwnerID(user.id)
|
||||
if (profile) {
|
||||
await Email.sendWelcome(res.email, profile.username)
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
export const remove = fn(z.string(), async (id) => {
|
||||
const db = databaseClient()
|
||||
|
||||
await db.transact(db.tx.subscriptions[id]!.update({
|
||||
canceledAt: new Date().toISOString()
|
||||
}))
|
||||
})
|
||||
|
||||
export const fromID = fn(z.string(), async (id) => {
|
||||
const db = databaseClient()
|
||||
const user = useCurrentUser()
|
||||
const query = {
|
||||
subscriptions: {
|
||||
$: {
|
||||
where: {
|
||||
id,
|
||||
//Make sure they can only get subscriptions they own
|
||||
owner: user.id,
|
||||
canceledAt: { $isNull: true }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
const response = res.subscriptions
|
||||
if (!response || response.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
response,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
checkoutID: group[0].checkoutID,
|
||||
// next: group[0].next,
|
||||
// frequency: group[0].frequency as any,
|
||||
// quantity: group[0].quantity,
|
||||
// productID: group[0].productID,
|
||||
}))
|
||||
)
|
||||
|
||||
return result[0]
|
||||
})
|
||||
|
||||
export const fromCheckoutID = fn(z.string(), async (id) => {
|
||||
const db = databaseClient()
|
||||
const user = useCurrentUser()
|
||||
const query = {
|
||||
subscriptions: {
|
||||
$: {
|
||||
where: {
|
||||
id,
|
||||
//Make sure they can only get subscriptions they own
|
||||
checkoutID: id,
|
||||
canceledAt: { $isNull: true }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
const response = res.subscriptions
|
||||
if (!response || response.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
response,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
checkoutID: group[0].checkoutID,
|
||||
}))
|
||||
)
|
||||
|
||||
return result[0]
|
||||
})
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Resource } from "sst";
|
||||
import { Aws } from "../aws/client";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import databaseClient from "../database"
|
||||
import { useCurrentUser } from "../actor";
|
||||
import { id as createID } from "@instantdb/admin";
|
||||
import { groupBy, map, pipe, values } from "remeda"
|
||||
import { Sessions } from "../session";
|
||||
|
||||
export const lastStatus = z.enum([
|
||||
"RUNNING",
|
||||
"PENDING",
|
||||
"UNKNOWN",
|
||||
"STOPPED",
|
||||
]);
|
||||
|
||||
export const taskType = z.enum([
|
||||
"AWS",
|
||||
"ON_PREMISES",
|
||||
"UNKNOWN"
|
||||
]);
|
||||
|
||||
export const healthStatus = z.enum([
|
||||
"HEALTHY",
|
||||
"UNHEALTHY",
|
||||
"UNKNOWN",
|
||||
]);
|
||||
|
||||
export type taskType = z.infer<typeof taskType>;
|
||||
export type lastStatus = z.infer<typeof lastStatus>;
|
||||
export type healthStatus = z.infer<typeof healthStatus>;
|
||||
|
||||
export module Tasks {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Task.id,
|
||||
}),
|
||||
type: taskType.openapi({
|
||||
description: "Where this task is hosted on",
|
||||
example: Examples.Task.type,
|
||||
}),
|
||||
taskID: z.string().openapi({
|
||||
description: "The id of this task as seen on AWS",
|
||||
example: Examples.Task.taskID,
|
||||
}),
|
||||
startedAt: z.string().or(z.number()).openapi({
|
||||
description: "The time this task was started",
|
||||
example: Examples.Task.startedAt,
|
||||
}),
|
||||
lastUpdated: z.string().or(z.number()).openapi({
|
||||
description: "The time the information about this task was last updated",
|
||||
example: Examples.Task.lastUpdated,
|
||||
}),
|
||||
stoppedAt: z.string().or(z.number()).optional().openapi({
|
||||
description: "The time this task was stopped or quit",
|
||||
example: Examples.Task.lastUpdated,
|
||||
}),
|
||||
lastStatus: lastStatus.openapi({
|
||||
description: "The last registered status of this task",
|
||||
example: Examples.Task.lastStatus,
|
||||
}),
|
||||
healthStatus: healthStatus.openapi({
|
||||
description: "The health status of this task",
|
||||
example: Examples.Task.healthStatus,
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Subscription",
|
||||
description: "Subscription to a Nestri product.",
|
||||
example: Examples.Task,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const list = async () => {
|
||||
const db = databaseClient()
|
||||
const user = useCurrentUser()
|
||||
|
||||
try {
|
||||
const query = {
|
||||
tasks: {
|
||||
$: {
|
||||
where: {
|
||||
stoppedAt: { $isNull: true },
|
||||
owner: user.id
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const data = await db.query(query)
|
||||
|
||||
const response = data.tasks
|
||||
if (!response || response.length === 0) {
|
||||
throw new Error("No task for this user were found");
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
response,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
taskID: group[0].taskID,
|
||||
type: group[0].type as taskType,
|
||||
lastStatus: group[0].lastStatus as lastStatus,
|
||||
healthStatus: group[0].healthStatus as healthStatus,
|
||||
startedAt: group[0].startedAt,
|
||||
stoppedAt: group[0].stoppedAt,
|
||||
lastUpdated: group[0].lastUpdated,
|
||||
}))
|
||||
)
|
||||
|
||||
return result
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const create = async () => {
|
||||
const user = useCurrentUser()
|
||||
|
||||
try {
|
||||
|
||||
//TODO: Use a simpler way to set the session ID
|
||||
// const sessionID = createID()
|
||||
|
||||
const sessionID = await Sessions.create({ public: true })
|
||||
if (!sessionID) throw new Error("No session id was given");
|
||||
|
||||
const run = await Aws.EcsRunTask({
|
||||
count: 1,
|
||||
cluster: Resource.NestriGPUCluster.value,
|
||||
taskDefinition: Resource.NestriGPUTask.value,
|
||||
launchType: "EC2",
|
||||
overrides: {
|
||||
containerOverrides: [
|
||||
{
|
||||
name: "nestri",
|
||||
environment: [
|
||||
{
|
||||
name: "NESTRI_ROOM",
|
||||
value: sessionID
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
if (!run.tasks || run.tasks.length === 0) {
|
||||
throw new Error(`No tasks were started`);
|
||||
}
|
||||
|
||||
// Extract task details
|
||||
const task = run.tasks[0];
|
||||
const taskArn = task?.taskArn!;
|
||||
const taskId = taskArn.split('/').pop()!; // Extract task ID from ARN
|
||||
const taskStatus = task?.lastStatus;
|
||||
const taskHealthStatus = task?.healthStatus;
|
||||
const startedAt = task?.startedAt!;
|
||||
|
||||
const id = createID()
|
||||
const db = databaseClient()
|
||||
const now = new Date().toISOString()
|
||||
await db.transact(db.tx.tasks[id]!.update({
|
||||
taskID: taskId,
|
||||
type: "AWS",
|
||||
healthStatus: taskHealthStatus ? taskHealthStatus.toString() : "UNKNOWN",
|
||||
startedAt: startedAt ? startedAt.toISOString() : now,
|
||||
lastStatus: taskStatus,
|
||||
lastUpdated: now,
|
||||
}).link({ owner: user.id, sessions: sessionID }))
|
||||
|
||||
return id
|
||||
} catch (e) {
|
||||
console.error("error", e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const fromID = fn(z.string(), async (taskID) => {
|
||||
const db = databaseClient()
|
||||
try {
|
||||
const query = {
|
||||
tasks: {
|
||||
$: {
|
||||
where: {
|
||||
id: taskID,
|
||||
stoppedAt: { $isNull: true }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const data = await db.query(query)
|
||||
|
||||
const response = data.tasks
|
||||
if (!response || response.length === 0) {
|
||||
throw new Error("No task with the given id was found");
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
response,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
taskID: group[0].taskID,
|
||||
type: group[0].type as taskType,
|
||||
lastStatus: group[0].lastStatus as lastStatus,
|
||||
healthStatus: group[0].healthStatus as healthStatus,
|
||||
startedAt: group[0].startedAt,
|
||||
stoppedAt: group[0].stoppedAt,
|
||||
lastUpdated: group[0].lastUpdated,
|
||||
}))
|
||||
)
|
||||
|
||||
return result[0]
|
||||
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
export const update = fn(z.string(), async (taskID) => {
|
||||
try {
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
tasks: {
|
||||
$: {
|
||||
where: {
|
||||
id: taskID,
|
||||
stoppedAt: { $isNull: true }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const data = await db.query(query)
|
||||
|
||||
const response = data.tasks
|
||||
if (!response || response.length === 0) {
|
||||
throw new Error("No task with the given taskID was found");
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const describeResponse = await Aws.EcsDescribeTasks({
|
||||
tasks: [response[0]!.taskID],
|
||||
cluster: Resource.NestriGPUCluster.value
|
||||
})
|
||||
|
||||
if (!describeResponse.tasks || describeResponse.tasks.length === 0) {
|
||||
throw new Error("No tasks were found");
|
||||
}
|
||||
|
||||
const task = describeResponse.tasks[0]!
|
||||
|
||||
const updatedDb = {
|
||||
healthStatus: task.healthStatus ? task.healthStatus : "UNKNOWN",
|
||||
lastStatus: task.lastStatus ? task.lastStatus : "UNKNOWN",
|
||||
lastUpdated: now,
|
||||
}
|
||||
|
||||
await db.transact(db.tx.tasks[response[0]!.id]!.update({
|
||||
...updatedDb
|
||||
}))
|
||||
|
||||
const updatedRes = [{ ...response[0]!, ...updatedDb }]
|
||||
|
||||
const result = pipe(
|
||||
updatedRes,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
taskID: group[0].taskID,
|
||||
type: group[0].type as taskType,
|
||||
lastStatus: group[0].lastStatus as lastStatus,
|
||||
healthStatus: group[0].healthStatus as healthStatus,
|
||||
startedAt: group[0].startedAt,
|
||||
stoppedAt: group[0].stoppedAt,
|
||||
lastUpdated: group[0].lastUpdated,
|
||||
}))
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
} catch (error) {
|
||||
console.error("update error", error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
export const stop = fn(z.object({ taskID: z.string(), id: z.string() }), async (input) => {
|
||||
const db = databaseClient()
|
||||
const now = new Date().toISOString()
|
||||
try {
|
||||
//TODO:Check whether they own this task first
|
||||
|
||||
const stopResponse = await Aws.EcsStopTask({
|
||||
task: input.taskID,
|
||||
cluster: Resource.NestriGPUCluster.value,
|
||||
reason: "Client requested a shutdown"
|
||||
})
|
||||
|
||||
if (!stopResponse.task) {
|
||||
throw new Error(`No task was stopped`);
|
||||
}
|
||||
|
||||
await db.transact(db.tx.tasks[input.id]!.update({
|
||||
stoppedAt: now,
|
||||
lastUpdated: now,
|
||||
lastStatus: "STOPPED",
|
||||
healthStatus: "UNKNOWN"
|
||||
}))
|
||||
|
||||
return "ok"
|
||||
|
||||
} catch (error) {
|
||||
console.error("stop error", error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,164 +1,152 @@
|
||||
import { z } from "zod";
|
||||
import databaseClient from "../database"
|
||||
import { fn } from "../utils";
|
||||
import { groupBy, map, pipe, values } from "remeda"
|
||||
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 { useCurrentUser } from "../actor";
|
||||
import { id as createID } from "@instantdb/admin";
|
||||
import { teamTable } from "./team.sql";
|
||||
import { createEvent } from "../event";
|
||||
import { assertActor } from "../actor";
|
||||
import { and, eq, sql } from "../drizzle";
|
||||
import { memberTable } from "../member/member.sql";
|
||||
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Teams {
|
||||
export module Team {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Team.id,
|
||||
}),
|
||||
name: z.string().openapi({
|
||||
description: "Name of the team",
|
||||
example: Examples.Team.name,
|
||||
}),
|
||||
createdAt: z.string().or(z.number()).openapi({
|
||||
description: "The time when this team was first created",
|
||||
example: Examples.Team.createdAt,
|
||||
}),
|
||||
updatedAt: z.string().or(z.number()).openapi({
|
||||
description: "The time when this team was last edited",
|
||||
example: Examples.Team.updatedAt,
|
||||
}),
|
||||
// owner: z.boolean().openapi({
|
||||
// description: "Whether this team is owned by this user",
|
||||
// example: Examples.Team.owner,
|
||||
// }),
|
||||
slug: z.string().openapi({
|
||||
description: "This is the unique name identifier for the team",
|
||||
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
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Team",
|
||||
description: "A group of users sharing the same machines for gaming.",
|
||||
description: "Represents a team on Nestri",
|
||||
example: Examples.Team,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const list = async () => {
|
||||
const db = databaseClient()
|
||||
const user = useCurrentUser()
|
||||
export const Events = {
|
||||
Created: createEvent(
|
||||
"team.created",
|
||||
z.object({
|
||||
teamID: z.string().nonempty(),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
const query = {
|
||||
teams: {
|
||||
$: {
|
||||
where: {
|
||||
members: user.id,
|
||||
deletedAt: { $isNull: true }
|
||||
}
|
||||
},
|
||||
}
|
||||
export class WorkspaceExistsError extends VisibleError {
|
||||
constructor(slug: string) {
|
||||
super(
|
||||
"team.slug_exists",
|
||||
`there is already a workspace named "${slug}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
const teams = res.teams
|
||||
if (!teams || teams.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const result = pipe(
|
||||
teams,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
name: group[0].name,
|
||||
createdAt: group[0].createdAt,
|
||||
updatedAt: group[0].updatedAt,
|
||||
slug: group[0].slug,
|
||||
//@ts-expect-error
|
||||
owner: group[0].owner === user.id
|
||||
}))
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export const create = fn(
|
||||
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,
|
||||
slug: input.slug,
|
||||
name: input.name
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ insertedID: teamTable.id })
|
||||
|
||||
export const fromSlug = fn(z.string(), async (slug) => {
|
||||
const db = databaseClient()
|
||||
if (result.length === 0) throw new WorkspaceExistsError(input.slug);
|
||||
|
||||
const query = {
|
||||
teams: {
|
||||
$: {
|
||||
where: {
|
||||
slug,
|
||||
deletedAt: { $isNull: true }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
await afterTx(() =>
|
||||
bus.publish(Resource.Bus, Events.Created, {
|
||||
teamID: id,
|
||||
}),
|
||||
);
|
||||
return id;
|
||||
})
|
||||
})
|
||||
|
||||
const res = await db.query(query)
|
||||
export const remove = fn(Info.shape.id, (input) =>
|
||||
useTransaction(async (tx) => {
|
||||
const account = assertActor("user");
|
||||
const row = await tx
|
||||
.select({
|
||||
teamID: memberTable.teamID,
|
||||
})
|
||||
.from(memberTable)
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.teamID, input),
|
||||
eq(memberTable.email, account.properties.email),
|
||||
),
|
||||
)
|
||||
.execute()
|
||||
.then((rows) => rows.at(0));
|
||||
if (!row) return;
|
||||
await tx
|
||||
.update(teamTable)
|
||||
.set({
|
||||
timeDeleted: sql`now()`,
|
||||
})
|
||||
.where(eq(teamTable.id, row.teamID));
|
||||
}),
|
||||
);
|
||||
|
||||
const teams = res.teams
|
||||
if (!teams || teams.length === 0) {
|
||||
return null
|
||||
}
|
||||
export const list = fn(z.void(), () =>
|
||||
useTransaction((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(teamTable)
|
||||
.execute()
|
||||
.then((rows) => rows.map(serialize)),
|
||||
),
|
||||
);
|
||||
|
||||
const result = pipe(
|
||||
teams,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
name: group[0].name,
|
||||
slug: group[0].slug,
|
||||
createdAt: group[0].createdAt,
|
||||
updatedAt: group[0].updatedAt,
|
||||
// owner: group[0].owner === user.id
|
||||
}))
|
||||
)
|
||||
export const fromID = fn(z.string().min(1), async (id) =>
|
||||
useTransaction(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(teamTable)
|
||||
.where(eq(teamTable.id, id))
|
||||
.execute()
|
||||
.then((rows) => rows.map(serialize))
|
||||
.then((rows) => rows.at(0));
|
||||
}),
|
||||
);
|
||||
|
||||
return result[0]
|
||||
})
|
||||
export const fromSlug = fn(z.string().min(1), async (input) =>
|
||||
useTransaction(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(teamTable)
|
||||
.where(eq(teamTable.slug, input))
|
||||
.execute()
|
||||
.then((rows) => rows.map(serialize))
|
||||
.then((rows) => rows.at(0));
|
||||
}),
|
||||
);
|
||||
|
||||
export const create = fn(Info.pick({ name: true, slug: true }), async (input) => {
|
||||
const id = createID()
|
||||
const db = databaseClient()
|
||||
const user = useCurrentUser()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.transact(db.tx.teams[id]!.update({
|
||||
export function serialize(
|
||||
input: typeof teamTable.$inferSelect,
|
||||
): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).link({ owner: user.id, members: user.id }))
|
||||
|
||||
return id
|
||||
})
|
||||
|
||||
export const remove = fn(z.string(), async (id) => {
|
||||
const db = databaseClient()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.transact(db.tx.teams[id]!.update({
|
||||
deletedAt: now
|
||||
}))
|
||||
|
||||
return "ok"
|
||||
})
|
||||
|
||||
export const invite = fn(z.object({email:z.string(), id: z.string()}), async (input) => {
|
||||
//TODO:
|
||||
// const db = databaseClient()
|
||||
// const now = new Date().toISOString()
|
||||
|
||||
// await db.transact(db.tx.teams[id]!.update({
|
||||
// deletedAt: now
|
||||
// }))
|
||||
|
||||
return "ok"
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
27
packages/core/src/team/team.sql.ts
Normal file
27
packages/core/src/team/team.sql.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import {} from "drizzle-orm/postgres-js";
|
||||
import { timestamps, id } from "../drizzle/types";
|
||||
import {
|
||||
pgTable,
|
||||
primaryKey,
|
||||
uniqueIndex,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
export const teamTable = pgTable(
|
||||
"team",
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
slug: varchar("slug", { length: 255 }).notNull(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
},
|
||||
(table) => [uniqueIndex("slug").on(table.slug)],
|
||||
);
|
||||
|
||||
export function teamIndexes(table: any) {
|
||||
return [
|
||||
primaryKey({
|
||||
columns: [table.teamID, table.id],
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
export interface CloudflareCF {
|
||||
colo: string;
|
||||
continent: string;
|
||||
country: string,
|
||||
city: string;
|
||||
region: string;
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
metroCode: string;
|
||||
postalCode: string;
|
||||
timezone: string;
|
||||
regionCode: number;
|
||||
}
|
||||
|
||||
export interface CFRequest extends Request {
|
||||
cf: CloudflareCF
|
||||
}
|
||||
@@ -1,37 +1,219 @@
|
||||
import { z } from "zod";
|
||||
import databaseClient from "../database"
|
||||
import { fn } from "../utils";
|
||||
import { bus } from "sst/aws/bus";
|
||||
import { Common } from "../common";
|
||||
import { createID, fn } from "../utils";
|
||||
import { userTable } from "./user.sql";
|
||||
import { createEvent } from "../event";
|
||||
import { Examples } from "../examples";
|
||||
import { Resource } from "sst/resource";
|
||||
import { teamTable } from "../team/team.sql";
|
||||
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 {
|
||||
const MAX_ATTEMPTS = 50;
|
||||
|
||||
export module Users {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.User.id,
|
||||
}),
|
||||
email: z.string().nullable().openapi({
|
||||
description: "Email address of the user.",
|
||||
name: z.string().openapi({
|
||||
description: "The user's unique username",
|
||||
example: Examples.User.name,
|
||||
}),
|
||||
polarCustomerID: z.string().or(z.null()).openapi({
|
||||
description: "The polar customer id for this user",
|
||||
example: Examples.User.polarCustomerID,
|
||||
}),
|
||||
email: z.string().openapi({
|
||||
description: "The email address of this user",
|
||||
example: Examples.User.email,
|
||||
}),
|
||||
avatarUrl: z.string().or(z.null()).openapi({
|
||||
description: "The url to the profile picture.",
|
||||
example: Examples.User.name,
|
||||
}),
|
||||
discriminator: z.string().or(z.number()).openapi({
|
||||
description: "The (number) discriminator for this user",
|
||||
example: Examples.User.discriminator,
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
ref: "User",
|
||||
description: "A Nestri console user.",
|
||||
description: "Represents a user on Nestri",
|
||||
example: Examples.User,
|
||||
});
|
||||
|
||||
export const fromEmail = fn(z.string(), async (email) => {
|
||||
const db = databaseClient()
|
||||
const res = await db.auth.getUser({ email })
|
||||
return res
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const Events = {
|
||||
Created: createEvent(
|
||||
"user.created",
|
||||
z.object({
|
||||
userID: Info.shape.id,
|
||||
}),
|
||||
),
|
||||
Updated: createEvent(
|
||||
"user.updated",
|
||||
z.object({
|
||||
userID: Info.shape.id,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
export const sanitizeUsername = (username: string): string => {
|
||||
// Remove spaces and numbers
|
||||
return username.replace(/[\s0-9]/g, '');
|
||||
};
|
||||
|
||||
export const generateDiscriminator = (): string => {
|
||||
return Math.floor(Math.random() * 100).toString().padStart(2, '0');
|
||||
};
|
||||
|
||||
export const isValidDiscriminator = (discriminator: string): boolean => {
|
||||
return /^\d{2}$/.test(discriminator);
|
||||
};
|
||||
|
||||
export const findAvailableDiscriminator = fn(z.string(), async (input) => {
|
||||
const username = sanitizeUsername(input);
|
||||
|
||||
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
||||
const discriminator = generateDiscriminator();
|
||||
|
||||
const users = await useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(userTable)
|
||||
.where(and(eq(userTable.name, username), eq(userTable.discriminator, Number(discriminator))))
|
||||
)
|
||||
|
||||
if (users.length === 0) {
|
||||
return discriminator;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
|
||||
export const create = fn(z.string(), async (email) => {
|
||||
const db = databaseClient()
|
||||
const token = await db.auth.createToken(email)
|
||||
export const create = fn(Info.omit({ polarCustomerID: true, discriminator: true }).partial({ avatarUrl: true, id: true }), async (input) => {
|
||||
const userID = createID("user")
|
||||
|
||||
return token
|
||||
//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 name = sanitizeUsername(input.name);
|
||||
|
||||
// Generate a random available discriminator
|
||||
const discriminator = await findAvailableDiscriminator(name);
|
||||
|
||||
if (!discriminator) {
|
||||
console.error("No available discriminators for this username ")
|
||||
return null
|
||||
}
|
||||
|
||||
createTransaction(async (tx) => {
|
||||
const id = input.id ?? userID;
|
||||
await tx.insert(userTable).values({
|
||||
id,
|
||||
name: input.name,
|
||||
avatarUrl: input.avatarUrl,
|
||||
email: input.email,
|
||||
discriminator: Number(discriminator),
|
||||
})
|
||||
await afterTx(() =>
|
||||
withActor({
|
||||
type: "user",
|
||||
properties: {
|
||||
userID: id,
|
||||
email: input.email
|
||||
},
|
||||
},
|
||||
async () => bus.publish(Resource.Bus, Events.Created, { userID: id }),
|
||||
)
|
||||
);
|
||||
})
|
||||
|
||||
return userID;
|
||||
})
|
||||
|
||||
export const fromEmail = fn(z.string(), async (email) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(userTable)
|
||||
.where(and(eq(userTable.email, email), isNull(userTable.timeDeleted)))
|
||||
.orderBy(asc(userTable.timeCreated))
|
||||
.then((rows) => rows.map(serialize))
|
||||
.then((rows) => rows.at(0))
|
||||
),
|
||||
)
|
||||
|
||||
export const fromID = fn(z.string(), async (id) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(userTable)
|
||||
.where(and(eq(userTable.id, id), isNull(userTable.timeDeleted)))
|
||||
.orderBy(asc(userTable.timeCreated))
|
||||
.then((rows) => rows.map(serialize))
|
||||
.then((rows) => rows.at(0))
|
||||
),
|
||||
)
|
||||
|
||||
export function serialize(
|
||||
input: typeof userTable.$inferSelect,
|
||||
): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
email: input.email,
|
||||
avatarUrl: input.avatarUrl,
|
||||
discriminator: input.discriminator,
|
||||
polarCustomerID: input.polarCustomerID,
|
||||
};
|
||||
}
|
||||
|
||||
export const remove = fn(Info.shape.id, (input) =>
|
||||
useTransaction(async (tx) => {
|
||||
await tx
|
||||
.update(userTable)
|
||||
.set({
|
||||
timeDeleted: sql`CURRENT_TIMESTAMP()`,
|
||||
})
|
||||
.where(and(eq(userTable.id, input)))
|
||||
.execute();
|
||||
return input;
|
||||
}),
|
||||
);
|
||||
|
||||
export function teams() {
|
||||
const actor = assertActor("user");
|
||||
return useTransaction((tx) =>
|
||||
tx
|
||||
.select(getTableColumns(teamTable))
|
||||
.from(teamTable)
|
||||
.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(Team.serialize))
|
||||
);
|
||||
}
|
||||
}
|
||||
27
packages/core/src/user/user.sql.ts
Normal file
27
packages/core/src/user/user.sql.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { z } from "zod";
|
||||
import { id, timestamps } from "../drizzle/types";
|
||||
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({
|
||||
team: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type UserFlags = z.infer<typeof UserFlags>;
|
||||
|
||||
export const userTable = pgTable(
|
||||
"user",
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
avatarUrl: text("avatar_url"),
|
||||
email: varchar("email", { length: 255 }).notNull(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
discriminator: integer("discriminator").notNull(),
|
||||
polarCustomerID: varchar("polar_customer_id", { length: 255 }).unique(),
|
||||
flags: json("flags").$type<UserFlags>().default({}),
|
||||
},
|
||||
(user) => [
|
||||
uniqueIndex("user_email").on(user.email),
|
||||
],
|
||||
);
|
||||
11
packages/core/src/utils/id.ts
Normal file
11
packages/core/src/utils/id.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ulid } from "ulid";
|
||||
|
||||
export const prefixes = {
|
||||
user: "usr",
|
||||
team: "tea",
|
||||
member: "mbr"
|
||||
} as const;
|
||||
|
||||
export function createID(prefix: keyof typeof prefixes): string {
|
||||
return [prefixes[prefix], ulid()].join("_");
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./fn"
|
||||
export * from "./fn"
|
||||
export * from "./id"
|
||||
Reference in New Issue
Block a user