mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 08:45:38 +02:00
⭐ feat: New account system with improved team management (#273)
Description <!-- Briefly describe the purpose and scope of your changes --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced comprehensive account management with combined user and team info. - Added advanced, context-aware logging utilities. - Implemented invite code generation for teams with uniqueness guarantees. - Expanded example data for users, teams, subscriptions, sessions, and games. - **Enhancements** - Refined user, team, member, and Steam account schemas for richer data and validation. - Streamlined user creation, login acknowledgment, and error handling. - Improved API authentication and unified actor context management. - Added persistent shared temporary volume support to API and auth services. - Enhanced Steam account management with create, update, and event notifications. - Refined team listing and serialization integrating Steam accounts as members. - Simplified event, context, and logging systems. - Updated API and auth middleware for better token handling and actor provisioning. - **Bug Fixes** - Fixed multiline log output to prefix each line with log level. - **Removals** - Removed machine and subscription management features, including schemas and DB tables. - Disabled machine-based authentication and removed related subject schemas. - Removed deprecated fields and legacy logic from member and team management. - Removed legacy event and error handling related to teams and members. - **Chores** - Reorganized and cleaned exports across utility and API modules. - Updated database schemas for users, teams, members, and Steam accounts. - Improved internal code structure, imports, and error messaging. - Moved logger patching to earlier initialization for consistent logging. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1,66 +1,62 @@
|
||||
import { z } from "zod";
|
||||
import { Team } from "../team";
|
||||
import { Resource } from "sst";
|
||||
import { bus } from "sst/aws/bus";
|
||||
import { Steam } from "../steam";
|
||||
import { Common } from "../common";
|
||||
import { createEvent } from "../event";
|
||||
import { Polar } from "../polar/index";
|
||||
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 { steamTable } from "../steam/steam.sql";
|
||||
import { assertActor, withActor } from "../actor";
|
||||
import { memberTable } from "../member/member.sql";
|
||||
import { pipe, groupBy, values, map } from "remeda";
|
||||
import { and, eq, isNull, asc, sql } from "../drizzle";
|
||||
import { subscriptionTable } from "../subscription/subscription.sql";
|
||||
import { and, eq, isNull, asc} from "drizzle-orm";
|
||||
import { ErrorCodes, VisibleError } from "../error";
|
||||
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
|
||||
export namespace User {
|
||||
const MAX_ATTEMPTS = 50;
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.User.id,
|
||||
}),
|
||||
name: z.string().openapi({
|
||||
description: "The user's unique username",
|
||||
example: Examples.User.name,
|
||||
name: z.string().regex(/^[a-zA-Z ]{1,32}$/, "Use a friendly name.").openapi({
|
||||
description: "The name of this account",
|
||||
example: Examples.User.name
|
||||
}),
|
||||
polarCustomerID: z.string().or(z.null()).openapi({
|
||||
description: "The polar customer id for this user",
|
||||
polarCustomerID: z.string().nullable().openapi({
|
||||
description: "Associated Polar.sh customer identifier",
|
||||
example: Examples.User.polarCustomerID,
|
||||
}),
|
||||
avatarUrl: z.string().url().nullable().openapi({
|
||||
description: "The url to the profile picture",
|
||||
example: Examples.User.avatarUrl
|
||||
}),
|
||||
email: z.string().openapi({
|
||||
description: "The email address of this user",
|
||||
description: "Primary email address for user notifications and authentication",
|
||||
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,
|
||||
}),
|
||||
steamAccounts: Steam.Info.array().openapi({
|
||||
description: "The steam accounts for this user",
|
||||
example: Examples.User.steamAccounts,
|
||||
}),
|
||||
lastLogin: z.date().openapi({
|
||||
description: "Timestamp of user's most recent authentication",
|
||||
example: Examples.User.lastLogin
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "User",
|
||||
description: "Represents a user on Nestri",
|
||||
description: "User account entity with core identification and authentication details",
|
||||
example: Examples.User,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export class UserExistsError extends VisibleError {
|
||||
constructor(username: string) {
|
||||
super(
|
||||
"already_exists",
|
||||
ErrorCodes.Validation.ALREADY_EXISTS,
|
||||
`A user with this email ${username} already exists`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const Events = {
|
||||
Created: createEvent(
|
||||
"user.created",
|
||||
@@ -68,187 +64,129 @@ export namespace User {
|
||||
userID: Info.shape.id,
|
||||
}),
|
||||
),
|
||||
Updated: createEvent(
|
||||
"user.updated",
|
||||
z.object({
|
||||
userID: Info.shape.id,
|
||||
};
|
||||
|
||||
export const create = fn(
|
||||
Info
|
||||
.omit({
|
||||
lastLogin: true,
|
||||
polarCustomerID: true,
|
||||
}).partial({
|
||||
avatarUrl: true,
|
||||
id: true
|
||||
}),
|
||||
),
|
||||
};
|
||||
async (input) => {
|
||||
const userID = createID("user")
|
||||
|
||||
export const sanitizeUsername = (username: string): string => {
|
||||
// Remove spaces and numbers
|
||||
return username.replace(/[\s0-9]/g, '');
|
||||
};
|
||||
const customer = await Polar.fromUserEmail(input.email)
|
||||
|
||||
export const generateDiscriminator = (): string => {
|
||||
return Math.floor(Math.random() * 100).toString().padStart(2, '0');
|
||||
};
|
||||
const id = input.id ?? userID;
|
||||
|
||||
export const isValidDiscriminator = (discriminator: string): boolean => {
|
||||
return /^\d{2}$/.test(discriminator);
|
||||
};
|
||||
await createTransaction(async (tx) => {
|
||||
const result = await tx
|
||||
.insert(userTable)
|
||||
.values({
|
||||
id,
|
||||
avatarUrl: input.avatarUrl,
|
||||
email: input.email,
|
||||
name: input.name,
|
||||
polarCustomerID: customer?.id,
|
||||
lastLogin: Common.utc()
|
||||
})
|
||||
.onConflictDoNothing({
|
||||
target: [userTable.email]
|
||||
})
|
||||
|
||||
export const findAvailableDiscriminator = fn(z.string(), async (input) => {
|
||||
const username = sanitizeUsername(input);
|
||||
if (result.count === 0) {
|
||||
throw new UserExistsError(input.email)
|
||||
}
|
||||
|
||||
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
||||
const discriminator = generateDiscriminator();
|
||||
await afterTx(async () =>
|
||||
bus.publish(Resource.Bus, Events.Created, { userID: id })
|
||||
);
|
||||
})
|
||||
|
||||
const users = await useTransaction(async (tx) =>
|
||||
return id;
|
||||
})
|
||||
|
||||
export const fromEmail = fn(
|
||||
Info.shape.email.min(1),
|
||||
async (email) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(userTable)
|
||||
.where(and(eq(userTable.name, username), eq(userTable.discriminator, Number(discriminator))))
|
||||
.where(
|
||||
and(
|
||||
eq(userTable.email, email),
|
||||
isNull(userTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(userTable.timeCreated))
|
||||
.execute()
|
||||
.then(rows => rows.map(serialize).at(0))
|
||||
)
|
||||
|
||||
if (users.length === 0) {
|
||||
return discriminator;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
|
||||
export const create = fn(Info.omit({ polarCustomerID: true, discriminator: true, steamAccounts: true }).partial({ avatarUrl: true, id: true }), async (input) => {
|
||||
const userID = createID("user")
|
||||
|
||||
//FIXME: Do this much later, as Polar.sh has so many inconsistencies for fuck's sake
|
||||
|
||||
const customer = await Polar.fromUserEmail(input.email)
|
||||
console.log("customer", customer)
|
||||
|
||||
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),
|
||||
polarCustomerID: customer?.id
|
||||
})
|
||||
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)
|
||||
.leftJoin(steamTable, eq(userTable.id, steamTable.userID))
|
||||
.where(and(eq(userTable.email, email), isNull(userTable.timeDeleted)))
|
||||
.orderBy(asc(userTable.timeCreated))
|
||||
.then((rows => serialize(rows).at(0)))
|
||||
)
|
||||
)
|
||||
|
||||
export const fromID = fn(z.string(), (id) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(userTable)
|
||||
.leftJoin(steamTable, eq(userTable.id, steamTable.userID))
|
||||
.where(and(eq(userTable.id, id), isNull(userTable.timeDeleted), isNull(steamTable.timeDeleted)))
|
||||
.orderBy(asc(userTable.timeCreated))
|
||||
.then((rows) => serialize(rows).at(0))
|
||||
),
|
||||
export const fromID = fn(
|
||||
Info.shape.id.min(1),
|
||||
(id) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(userTable)
|
||||
.where(
|
||||
and(
|
||||
eq(userTable.id, id),
|
||||
isNull(userTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(userTable.timeCreated))
|
||||
.execute()
|
||||
.then(rows => rows.map(serialize).at(0))
|
||||
),
|
||||
)
|
||||
|
||||
export const remove = fn(Info.shape.id, (id) =>
|
||||
useTransaction(async (tx) => {
|
||||
await tx
|
||||
.update(userTable)
|
||||
.set({
|
||||
timeDeleted: sql`now()`,
|
||||
})
|
||||
.where(and(eq(userTable.id, id)))
|
||||
.execute();
|
||||
return id;
|
||||
}),
|
||||
export const remove = fn(
|
||||
Info.shape.id.min(1),
|
||||
(id) =>
|
||||
useTransaction(async (tx) => {
|
||||
await tx
|
||||
.update(userTable)
|
||||
.set({
|
||||
timeDeleted: Common.utc(),
|
||||
})
|
||||
.where(and(eq(userTable.id, id)))
|
||||
.execute();
|
||||
return id;
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Converts an array of user and Steam account records into structured user objects with associated Steam accounts.
|
||||
*
|
||||
* @param input - An array of objects containing user data and optional Steam account data.
|
||||
* @returns An array of user objects, each including a list of their associated Steam accounts.
|
||||
*/
|
||||
export function serialize(
|
||||
input: { user: typeof userTable.$inferSelect; steam: typeof steamTable.$inferSelect | null }[],
|
||||
): z.infer<typeof Info>[] {
|
||||
return pipe(
|
||||
input,
|
||||
groupBy((row) => row.user.id),
|
||||
values(),
|
||||
map((group) => ({
|
||||
...group[0].user,
|
||||
steamAccounts: !group[0].steam ?
|
||||
[] :
|
||||
group.map((row) => ({
|
||||
id: row.steam!.id,
|
||||
lastSeen: row.steam!.lastSeen,
|
||||
countryCode: row.steam!.countryCode,
|
||||
username: row.steam!.username,
|
||||
steamID: row.steam!.steamID,
|
||||
lastGame: row.steam!.lastGame,
|
||||
limitation: row.steam!.limitation,
|
||||
steamEmail: row.steam!.steamEmail,
|
||||
userID: row.steam!.userID,
|
||||
personaName: row.steam!.personaName,
|
||||
avatarUrl: row.steam!.avatarUrl,
|
||||
})),
|
||||
})),
|
||||
)
|
||||
}
|
||||
export const acknowledgeLogin = fn(
|
||||
Info.shape.id,
|
||||
(id) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.update(userTable)
|
||||
.set({
|
||||
lastLogin: Common.utc(),
|
||||
})
|
||||
.where(and(eq(userTable.id, id)))
|
||||
.execute()
|
||||
|
||||
/**
|
||||
* Retrieves the list of teams that the current user belongs to.
|
||||
*
|
||||
* @returns An array of team information objects representing the user's active team memberships.
|
||||
*
|
||||
* @remark Only teams and memberships that have not been deleted are included in the result.
|
||||
*/
|
||||
export function teams() {
|
||||
const actor = assertActor("user");
|
||||
return useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(teamTable)
|
||||
.leftJoin(subscriptionTable, eq(subscriptionTable.teamID, teamTable.id))
|
||||
.innerJoin(memberTable, eq(memberTable.teamID, teamTable.id))
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.email, actor.properties.email),
|
||||
isNull(memberTable.timeDeleted),
|
||||
isNull(teamTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.execute()
|
||||
.then((rows) => Team.serialize(rows))
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
export function serialize(
|
||||
input: typeof userTable.$inferSelect
|
||||
): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
email: input.email,
|
||||
avatarUrl: input.avatarUrl,
|
||||
lastLogin: input.lastLogin,
|
||||
polarCustomerID: input.polarCustomerID,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user