feat(api): Connect Steam to main user account (#262)

## Description
This attempts to connect the Steam account to user account... for easier
management

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Enhanced user profiles and account views now display integrated Steam
account details and enriched team associations for a more comprehensive
experience.
- **Chores**
- Backend and database refinements have been implemented to improve
system stability, data integrity, and overall performance.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Wanjohi
2025-04-14 10:32:21 +03:00
committed by GitHub
parent 9a6826b069
commit e93099784c
16 changed files with 1500 additions and 504 deletions

View File

@@ -3,34 +3,6 @@ export namespace Examples {
export const Id = (prefix: keyof typeof prefixes) =>
`${prefixes[prefix]}_XXXXXXXXXXXXXXXXXXXXXXXXX`;
export const User = {
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 Team = {
id: Id("team"),
name: "John Does' Team",
slug: "john_doe",
planType: "BYOG" as const
}
export const Member = {
id: Id("member"),
email: "john@example.com",
teamID: Id("team"),
timeSeen: new Date("2025-02-23T13:39:52.249Z"),
}
export const Polar = {
teamID: Id("team"),
timeSeen: new Date("2025-02-23T13:39:52.249Z"),
}
export const Steam = {
id: Id("steam"),
userID: Id("user"),
@@ -52,6 +24,35 @@ export namespace Examples {
avatarUrl: "https://avatars.akamai.steamstatic.com/XXXXXXXXXXXX_full.jpg",
}
export const User = {
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",
steamAccounts: [Steam]
};
export const Team = {
id: Id("team"),
name: "John Does' Team",
slug: "john_doe",
planType: "BYOG" as const
}
export const Member = {
id: Id("member"),
email: "john@example.com",
teamID: Id("team"),
timeSeen: new Date("2025-02-23T13:39:52.249Z"),
}
export const Polar = {
teamID: Id("team"),
timeSeen: new Date("2025-02-23T13:39:52.249Z"),
}
export const Machine = {
id: Id("machine"),
userID: Id("user"),

View File

@@ -13,10 +13,10 @@ export namespace Machine {
description: Common.IdDescription,
example: Examples.Machine.id,
}),
userID: z.string().nullable().openapi({
description: "The userID of the user who owns this machine, in the case of BYOG",
example: Examples.Machine.userID
}),
// userID: z.string().nullable().openapi({
// description: "The userID of the user who owns this machine, in the case of BYOG",
// example: Examples.Machine.userID
// }),
country: z.string().openapi({
description: "The fullname of the country this machine is running in",
example: Examples.Machine.country
@@ -55,7 +55,7 @@ export namespace Machine {
timezone: input.timezone,
fingerprint: input.fingerprint,
countryCode: input.countryCode,
userID: input.userID,
// userID: input.userID,
location: { x: input.location.longitude, y: input.location.latitude },
})
@@ -68,26 +68,26 @@ export namespace Machine {
})
)
export const fromUserID = fn(z.string(), async (userID) =>
useTransaction(async (tx) =>
tx
.select()
.from(machineTable)
.where(and(eq(machineTable.userID, userID), isNull(machineTable.timeDeleted)))
.then((rows) => rows.map(serialize))
)
)
// export const fromUserID = fn(z.string(), async (userID) =>
// useTransaction(async (tx) =>
// tx
// .select()
// .from(machineTable)
// .where(and(eq(machineTable.userID, userID), isNull(machineTable.timeDeleted)))
// .then((rows) => rows.map(serialize))
// )
// )
export const list = fn(z.void(), async () =>
useTransaction(async (tx) =>
tx
.select()
.from(machineTable)
// Show only hosted machines, not BYOG machines
.where(and(isNull(machineTable.userID), isNull(machineTable.timeDeleted)))
.then((rows) => rows.map(serialize))
)
)
// export const list = fn(z.void(), async () =>
// useTransaction(async (tx) =>
// tx
// .select()
// .from(machineTable)
// // Show only hosted machines, not BYOG machines
// .where(and(isNull(machineTable.userID), isNull(machineTable.timeDeleted)))
// .then((rows) => rows.map(serialize))
// )
// )
export const fromID = fn(Info.shape.id, async (id) =>
useTransaction(async (tx) =>
@@ -144,7 +144,7 @@ export namespace Machine {
): z.infer<typeof Info> {
return {
id: input.id,
userID: input.userID,
// userID: input.userID,
country: input.country,
timezone: input.timezone,
fingerprint: input.fingerprint,

View File

@@ -14,7 +14,7 @@ export const machineTable = pgTable(
{
...id,
...timestamps,
userID: ulid("user_id"),
// userID: ulid("user_id"),
country: text('country').notNull(),
timezone: text('timezone').notNull(),
location: point('location', { mode: 'xy' }).notNull(),
@@ -35,6 +35,6 @@ export const machineTable = pgTable(
(table) => [
// uniqueIndex("external_id").on(table.externalID),
uniqueIndex("machine_fingerprint").on(table.fingerprint),
primaryKey({ columns: [table.userID, table.id], }),
// primaryKey({ columns: [table.userID, table.id], }),
],
);

View File

@@ -1,6 +1,7 @@
import { z } from "zod";
import { timestamps, userID, utc } from "../drizzle/types";
import { id, timestamps, ulid, userID, utc } from "../drizzle/types";
import { index, pgTable, integer, uniqueIndex, varchar, text, primaryKey, json } from "drizzle-orm/pg-core";
import { userTable } from "../user/user.sql";
// public string Username { get; set; } = string.Empty;
@@ -37,9 +38,14 @@ export type AccountLimitation = z.infer<typeof AccountLimitation>;
export const steamTable = pgTable(
"steam",
{
...userID,
...id,
...timestamps,
lastSeen: utc("time_seen"),
userID: ulid("user_id")
.notNull()
.references(() => userTable.id, {
onDelete: "cascade",
}),
lastSeen: utc("last_seen").notNull(),
steamID: integer("steam_id").notNull(),
avatarUrl: text("avatar_url").notNull(),
lastGame: json("last_game").$type<LastGame>().notNull(),
@@ -48,11 +54,5 @@ export const steamTable = pgTable(
steamEmail: varchar("steam_email", { length: 255 }).notNull(),
personaName: varchar("persona_name", { length: 255 }).notNull(),
limitation: json("limitation").$type<AccountLimitation>().notNull(),
},
(table) => [
primaryKey({
columns: [table.userID, table.id],
}),
uniqueIndex("steam_email").on(table.userID, table.steamEmail),
],
}
);

View File

@@ -6,13 +6,16 @@ import { Polar } from "../polar/index";
import { createID, fn } from "../utils";
import { userTable } from "./user.sql";
import { createEvent } from "../event";
import { pipe, groupBy, values, map } from "remeda";
import { Examples } from "../examples";
import { Resource } from "sst/resource";
import { teamTable } from "../team/team.sql";
import { steamTable } from "../steam/steam.sql";
import { assertActor, withActor } from "../actor";
import { memberTable } from "../member/member.sql";
import { and, eq, isNull, asc, getTableColumns, sql } from "../drizzle";
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
import { Steam } from "../steam";
export namespace User {
@@ -44,6 +47,10 @@ export namespace User {
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,
}),
})
.openapi({
ref: "User",
@@ -102,7 +109,7 @@ export namespace User {
return null;
})
export const create = fn(Info.omit({ polarCustomerID: true, discriminator: true }).partial({ avatarUrl: true, id: true }), async (input) => {
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
@@ -147,41 +154,92 @@ export namespace User {
})
export const fromEmail = fn(z.string(), async (email) =>
useTransaction(async (tx) =>
tx
useTransaction(async (tx) => {
const rows = await 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) => rows.map(serialize))
.then((rows) => rows.at(0))
),
const result = pipe(
rows,
groupBy((row) => row.user.id),
values(),
map(
(group): Info => ({
id: group[0].user.id,
name: group[0].user.name,
email: group[0].user.email,
avatarUrl: group[0].user.avatarUrl,
discriminator: group[0].user.discriminator,
polarCustomerID: group[0].user.polarCustomerID,
steamAccounts: !group[0].steam ?
[] :
group.map((row) => ({
id: row.steam!.id,
userID: row.steam!.userID,
steamID: row.steam!.steamID,
lastSeen: row.steam!.lastSeen,
avatarUrl: row.steam!.avatarUrl,
lastGame: row.steam!.lastGame,
username: row.steam!.username,
countryCode: row.steam!.countryCode,
steamEmail: row.steam!.steamEmail,
personaName: row.steam!.personaName,
limitation: row.steam!.limitation,
})),
})
)
)
return result[0]
}),
)
export const fromID = fn(z.string(), async (id) =>
useTransaction(async (tx) =>
tx
useTransaction(async (tx) => {
const rows = await tx
.select()
.from(userTable)
.leftJoin(steamTable, eq(userTable.id, steamTable.userID))
.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,
};
}
const result = pipe(
rows,
groupBy((row) => row.user.id),
values(),
map(
(group): Info => ({
id: group[0].user.id,
name: group[0].user.name,
email: group[0].user.email,
avatarUrl: group[0].user.avatarUrl,
discriminator: group[0].user.discriminator,
polarCustomerID: group[0].user.polarCustomerID,
steamAccounts: !group[0].steam ?
[] :
group.map((row) => ({
id: row.steam!.id,
userID: row.steam!.userID,
steamID: row.steam!.steamID,
lastSeen: row.steam!.lastSeen,
avatarUrl: row.steam!.avatarUrl,
lastGame: row.steam!.lastGame,
username: row.steam!.username,
countryCode: row.steam!.countryCode,
steamEmail: row.steam!.steamEmail,
personaName: row.steam!.personaName,
limitation: row.steam!.limitation,
})),
})
)
)
return result[0]
}),
)
export const remove = fn(Info.shape.id, (id) =>
useTransaction(async (tx) => {