mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-20 09:45:21 +03:00
feat: Sync to OSS repo
This commit is contained in:
22
packages/core/src/user/fingerprint.sql.ts
Normal file
22
packages/core/src/user/fingerprint.sql.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { index, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { id, timestamps, ulid, utc } from '../db/types.js';
|
||||
import { UserTable } from './user.sql.js';
|
||||
|
||||
export const UserFingerprintTable = pgTable(
|
||||
'user_fingerprint',
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
userId: ulid('user_id')
|
||||
.notNull()
|
||||
.references(() => UserTable.id, { onDelete: 'cascade' }),
|
||||
fingerprint: text('fingerprint').notNull(),
|
||||
name: text('name'),
|
||||
lastSeen: utc('last_seen')
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('user_fingerprint_fingerprint_unique').on(t.fingerprint),
|
||||
index('user_fingerprint_user_idx').on(t.userId)
|
||||
]
|
||||
);
|
||||
167
packages/core/src/user/fingerprint.ts
Normal file
167
packages/core/src/user/fingerprint.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { eq, and, isNull, sql } from 'drizzle-orm';
|
||||
import z from 'zod';
|
||||
|
||||
import { Database } from '../db/index.js';
|
||||
import { Examples } from '../examples.js';
|
||||
import { fn } from '../fn.js';
|
||||
import { UserFingerprintTable } from './fingerprint.sql.js';
|
||||
import { User } from './index.js';
|
||||
import { LinkedAccount } from './linked-account.js';
|
||||
|
||||
export namespace Fingerprint {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().meta({
|
||||
description: 'Unique identifier for the fingerprint record',
|
||||
example: Examples.Fingerprint.id
|
||||
}),
|
||||
userId: z.string().meta({
|
||||
description: 'The user this fingerprint belongs to',
|
||||
example: Examples.Fingerprint.userId
|
||||
}),
|
||||
fingerprint: z.string().meta({
|
||||
description: 'MD5 hex of the SSH public key',
|
||||
example: Examples.Fingerprint.fingerprint
|
||||
}),
|
||||
name: z.string().optional().nullable().meta({
|
||||
description: 'Human-readable label (e.g. "MacBook Air")',
|
||||
example: Examples.Fingerprint.name
|
||||
}),
|
||||
lastSeen: z.iso.datetime().optional().nullable().meta({
|
||||
description: 'Timestamp of last connection using this key',
|
||||
example: Examples.Fingerprint.lastSeen
|
||||
})
|
||||
})
|
||||
.meta({
|
||||
ref: 'Fingerprint',
|
||||
description: 'An SSH public key fingerprint linked to a user account',
|
||||
example: Examples.Fingerprint
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const create = fn(
|
||||
Info.pick({ id: true, userId: true, fingerprint: true, name: true }),
|
||||
async (input) => {
|
||||
await Database.use(async (tx) => {
|
||||
await tx.insert(UserFingerprintTable).values({
|
||||
id: input.id,
|
||||
userId: input.userId,
|
||||
fingerprint: input.fingerprint,
|
||||
name: input.name ?? null,
|
||||
lastSeen: null
|
||||
});
|
||||
});
|
||||
return input.id;
|
||||
}
|
||||
);
|
||||
|
||||
export const findByFingerprint = fn(Info.shape.fingerprint, async (fingerprint) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(UserFingerprintTable)
|
||||
.where(
|
||||
and(
|
||||
eq(UserFingerprintTable.fingerprint, fingerprint),
|
||||
isNull(UserFingerprintTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.then((rows) => rows.at(0) ?? null);
|
||||
});
|
||||
});
|
||||
|
||||
export const listByUser = fn(Info.shape.userId, async (userId) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(UserFingerprintTable)
|
||||
.where(
|
||||
and(eq(UserFingerprintTable.userId, userId), isNull(UserFingerprintTable.timeDeleted))
|
||||
)
|
||||
.orderBy(UserFingerprintTable.timeCreated);
|
||||
});
|
||||
});
|
||||
|
||||
export const updateName = fn(Info.pick({ id: true, name: true }), async (input) => {
|
||||
await Database.use(async (tx) => {
|
||||
await tx
|
||||
.update(UserFingerprintTable)
|
||||
.set({ name: input.name ?? null })
|
||||
.where(eq(UserFingerprintTable.id, input.id));
|
||||
});
|
||||
});
|
||||
|
||||
export const touchLastSeen = fn(Info.shape.id, async (id) => {
|
||||
await Database.use(async (tx) => {
|
||||
await tx
|
||||
.update(UserFingerprintTable)
|
||||
.set({ lastSeen: sql`now()` })
|
||||
.where(eq(UserFingerprintTable.id, id));
|
||||
});
|
||||
});
|
||||
|
||||
export const remove = fn(Info.shape.fingerprint, async (fingerprint) => {
|
||||
await Database.use(async (tx) => {
|
||||
await tx
|
||||
.update(UserFingerprintTable)
|
||||
.set({ timeDeleted: sql`now()` })
|
||||
.where(eq(UserFingerprintTable.fingerprint, fingerprint));
|
||||
});
|
||||
});
|
||||
|
||||
export const repoint = fn(Info.pick({ fingerprint: true, userId: true }), async (input) => {
|
||||
await Database.use(async (tx) => {
|
||||
await tx
|
||||
.update(UserFingerprintTable)
|
||||
.set({
|
||||
userId: input.userId,
|
||||
timeUpdated: sql`now()`
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(UserFingerprintTable.fingerprint, input.fingerprint),
|
||||
isNull(UserFingerprintTable.timeDeleted)
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
export const mergeFingerprint = fn(
|
||||
Info.pick({ fingerprint: true }).extend({ targetUserId: z.string() }),
|
||||
async (input) => {
|
||||
const fp = await findByFingerprint(input.fingerprint);
|
||||
if (!fp) {
|
||||
throw new Error('Fingerprint not found');
|
||||
}
|
||||
|
||||
if (fp.userId === input.targetUserId) {
|
||||
return { merged: false as const, reason: 'already_owned' as const };
|
||||
}
|
||||
|
||||
const orphanLinkedAccounts = await LinkedAccount.listByUser(fp.userId);
|
||||
if (orphanLinkedAccounts.length > 0) {
|
||||
throw new Error(
|
||||
'This device already has linked accounts. Unlink them first before merging.'
|
||||
);
|
||||
}
|
||||
|
||||
await Database.transaction(async () => {
|
||||
await repoint({ fingerprint: input.fingerprint, userId: input.targetUserId });
|
||||
await User.remove(fp.userId);
|
||||
});
|
||||
|
||||
return { merged: true as const, targetUserId: input.targetUserId };
|
||||
}
|
||||
);
|
||||
|
||||
export function serialize(input: typeof UserFingerprintTable.$inferSelect): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
userId: input.userId,
|
||||
fingerprint: input.fingerprint,
|
||||
name: input.name,
|
||||
lastSeen: input.lastSeen?.toISOString() ?? null
|
||||
};
|
||||
}
|
||||
}
|
||||
124
packages/core/src/user/index.ts
Normal file
124
packages/core/src/user/index.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { eq, and, isNull, sql } from 'drizzle-orm';
|
||||
import z from 'zod';
|
||||
|
||||
import { Database } from '../db/index.js';
|
||||
import { Examples } from '../examples.js';
|
||||
import { fn } from '../fn.js';
|
||||
import { UserTable } from './user.sql.js';
|
||||
|
||||
export namespace User {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().meta({
|
||||
description: 'Unique identifier for the user record',
|
||||
example: Examples.User.id
|
||||
}),
|
||||
name: z.string().meta({
|
||||
description: 'The display name associated with this account',
|
||||
example: Examples.User.name
|
||||
}),
|
||||
email: z.email().optional().nullable().optional().meta({
|
||||
description:
|
||||
'Primary email address for account notifications and billing. May be null for gaming-only accounts.',
|
||||
example: Examples.User.email
|
||||
}),
|
||||
emailVerified: z.boolean().meta({
|
||||
description: 'Indicates whether the email address has been verified',
|
||||
example: Examples.User.emailVerified
|
||||
}),
|
||||
image: z.string().nullable().optional().meta({
|
||||
description: "URL pointing to the user's profile picture",
|
||||
example: Examples.User.image
|
||||
})
|
||||
})
|
||||
.meta({
|
||||
ref: 'User',
|
||||
description: 'User account entity with core identification details',
|
||||
example: Examples.User
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const create = fn(
|
||||
Info.pick({ id: true, name: true, email: true, emailVerified: true, image: true }),
|
||||
async (input) => {
|
||||
await Database.use(async (tx) => {
|
||||
await tx
|
||||
.insert(UserTable)
|
||||
.values({
|
||||
id: input.id,
|
||||
name: input.name || 'Player',
|
||||
email: input.email ?? null,
|
||||
emailVerified: input.emailVerified ?? false,
|
||||
image: input.image ?? null
|
||||
})
|
||||
.onConflictDoNothing({ target: UserTable.id });
|
||||
});
|
||||
return input.id;
|
||||
}
|
||||
);
|
||||
|
||||
export const fromID = fn(Info.shape.id, async (id) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(UserTable)
|
||||
.where(and(eq(UserTable.id, id), isNull(UserTable.timeDeleted)))
|
||||
.then((rows) => rows.at(0) ?? null);
|
||||
});
|
||||
});
|
||||
|
||||
export const fromEmail = fn(z.email(), async (email) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(UserTable)
|
||||
.where(and(eq(UserTable.email, email), isNull(UserTable.timeDeleted)))
|
||||
.then((rows) => rows.at(0) ?? null);
|
||||
});
|
||||
});
|
||||
|
||||
export async function list() {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(UserTable)
|
||||
.where(isNull(UserTable.timeDeleted))
|
||||
.orderBy(UserTable.timeCreated);
|
||||
});
|
||||
}
|
||||
|
||||
export const updateEmail = fn(
|
||||
Info.pick({ id: true, emailVerified: true }).extend({ email: z.email() }),
|
||||
async (input) => {
|
||||
await Database.use(async (tx) => {
|
||||
await tx
|
||||
.update(UserTable)
|
||||
.set({
|
||||
email: input.email,
|
||||
emailVerified: input.emailVerified ?? false
|
||||
})
|
||||
.where(eq(UserTable.id, input.id));
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const remove = fn(Info.shape.id, async (id) => {
|
||||
await Database.use(async (tx) => {
|
||||
await tx
|
||||
.update(UserTable)
|
||||
.set({ timeDeleted: sql`now()` })
|
||||
.where(eq(UserTable.id, id));
|
||||
});
|
||||
});
|
||||
|
||||
export function serialize(input: typeof UserTable.$inferSelect): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
email: input.email,
|
||||
emailVerified: input.emailVerified,
|
||||
image: input.image
|
||||
};
|
||||
}
|
||||
}
|
||||
27
packages/core/src/user/library.sql.ts
Normal file
27
packages/core/src/user/library.sql.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { index, integer, pgTable, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { id, timestamps, ulid, utc } from '../db/types.js';
|
||||
import { GameTable } from '../game/game.sql.js';
|
||||
import { UserTable } from '../user/user.sql.js';
|
||||
|
||||
export const UserLibraryTable = pgTable(
|
||||
'user_library',
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
userId: ulid('user_id')
|
||||
.notNull()
|
||||
.references(() => UserTable.id, { onDelete: 'cascade' }),
|
||||
gameId: ulid('game_id')
|
||||
.notNull()
|
||||
.references(() => GameTable.id, { onDelete: 'cascade' }),
|
||||
playtime2w: integer('playtime_2w'),
|
||||
playtimeForever: integer('playtime_forever'),
|
||||
lastPlayed: utc('last_played')
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('user_library_user_game_unique').on(t.userId, t.gameId),
|
||||
index('user_library_user_idx').on(t.userId),
|
||||
index('user_library_game_idx').on(t.gameId)
|
||||
]
|
||||
);
|
||||
234
packages/core/src/user/library.ts
Normal file
234
packages/core/src/user/library.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { eq, and, isNull, sql, inArray } from 'drizzle-orm';
|
||||
import z from 'zod';
|
||||
|
||||
import { Database } from '../db/index.js';
|
||||
import { Examples } from '../examples.js';
|
||||
import { fn } from '../fn.js';
|
||||
import { GameDownload } from '../game/download.js';
|
||||
import { GameDownloadTable } from '../game/download.sql.js';
|
||||
import { GameTable } from '../game/game.sql.js';
|
||||
import { Game } from '../game/index.js';
|
||||
import { UserLibraryTable } from './library.sql.js';
|
||||
|
||||
export namespace Library {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().meta({
|
||||
description: 'Unique identifier for the library entry',
|
||||
example: Examples.Library.id
|
||||
}),
|
||||
userId: z.string().meta({
|
||||
description: 'The user who owns this game',
|
||||
example: Examples.Library.userId
|
||||
}),
|
||||
gameId: z.string().meta({
|
||||
description: 'The game in the library',
|
||||
example: Examples.Library.gameId
|
||||
}),
|
||||
playtime2w: z.number().int().nullable().optional().meta({
|
||||
description: 'Playtime in seconds over the last 2 weeks',
|
||||
example: Examples.Library.playtime2w
|
||||
}),
|
||||
playtimeForever: z.number().int().nullable().optional().meta({
|
||||
description: 'Total playtime in seconds',
|
||||
example: Examples.Library.playtimeForever
|
||||
}),
|
||||
lastPlayed: z.string().nullable().optional().meta({
|
||||
description: 'Last time the game was played (ISO 8601)',
|
||||
example: Examples.Library.lastPlayed
|
||||
})
|
||||
})
|
||||
.meta({
|
||||
ref: 'Library',
|
||||
description: 'Links a user to a game in their library with playtime info',
|
||||
example: Examples.Library
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const create = fn(Info, async (input) => {
|
||||
await Database.use(async (tx) => {
|
||||
await tx.insert(UserLibraryTable).values({
|
||||
id: input.id,
|
||||
userId: input.userId,
|
||||
gameId: input.gameId,
|
||||
playtime2w: input.playtime2w ?? null,
|
||||
playtimeForever: input.playtimeForever ?? null,
|
||||
lastPlayed: input.lastPlayed ? new Date(input.lastPlayed) : null
|
||||
});
|
||||
});
|
||||
return input.id;
|
||||
});
|
||||
|
||||
export const upsert = fn(
|
||||
Info.pick({
|
||||
id: true,
|
||||
userId: true,
|
||||
gameId: true,
|
||||
playtime2w: true,
|
||||
playtimeForever: true,
|
||||
lastPlayed: true
|
||||
}),
|
||||
async (input) => {
|
||||
await Database.use(async (tx) => {
|
||||
await tx
|
||||
.insert(UserLibraryTable)
|
||||
.values({
|
||||
id: input.id,
|
||||
userId: input.userId,
|
||||
gameId: input.gameId,
|
||||
playtime2w: input.playtime2w ?? null,
|
||||
playtimeForever: input.playtimeForever ?? null,
|
||||
lastPlayed: input.lastPlayed ? new Date(input.lastPlayed) : null
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [UserLibraryTable.userId, UserLibraryTable.gameId],
|
||||
set: {
|
||||
playtime2w: input.playtime2w ?? null,
|
||||
playtimeForever: input.playtimeForever ?? null,
|
||||
lastPlayed: input.lastPlayed ? new Date(input.lastPlayed) : null
|
||||
}
|
||||
});
|
||||
});
|
||||
return input.id;
|
||||
}
|
||||
);
|
||||
|
||||
export const fromID = fn(Info.shape.id, async (id) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(UserLibraryTable)
|
||||
.where(and(eq(UserLibraryTable.id, id), isNull(UserLibraryTable.timeDeleted)))
|
||||
.then((rows) => rows.at(0) ?? null);
|
||||
});
|
||||
});
|
||||
|
||||
export const findByUserAndGame = fn(Info.pick({ userId: true, gameId: true }), async (input) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(UserLibraryTable)
|
||||
.where(
|
||||
and(
|
||||
eq(UserLibraryTable.userId, input.userId),
|
||||
eq(UserLibraryTable.gameId, input.gameId),
|
||||
isNull(UserLibraryTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.then((rows) => rows.at(0) ?? null);
|
||||
});
|
||||
});
|
||||
|
||||
export const listByUser = fn(Info.shape.userId, async (userId) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(UserLibraryTable)
|
||||
.where(and(eq(UserLibraryTable.userId, userId), isNull(UserLibraryTable.timeDeleted)))
|
||||
.orderBy(UserLibraryTable.timeCreated);
|
||||
});
|
||||
});
|
||||
|
||||
export const listByUserWithGames = fn(Info.shape.userId, async (userId) => {
|
||||
return Database.use(async (tx) => {
|
||||
const rows = await tx
|
||||
.select({
|
||||
library: UserLibraryTable,
|
||||
game: GameTable
|
||||
})
|
||||
.from(UserLibraryTable)
|
||||
.leftJoin(GameTable, eq(UserLibraryTable.gameId, GameTable.id))
|
||||
.where(and(eq(UserLibraryTable.userId, userId), isNull(UserLibraryTable.timeDeleted)))
|
||||
.orderBy(UserLibraryTable.timeCreated);
|
||||
|
||||
const gameIds = [
|
||||
...new Set(rows.filter((row) => row.game !== null).map((row) => row.library.gameId))
|
||||
];
|
||||
const downloadRows =
|
||||
gameIds.length > 0
|
||||
? await tx
|
||||
.select()
|
||||
.from(GameDownloadTable)
|
||||
.where(
|
||||
and(
|
||||
inArray(GameDownloadTable.gameId, gameIds),
|
||||
isNull(GameDownloadTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
: [];
|
||||
|
||||
// A game may have one state row per host; surface the most recently
|
||||
// updated state for the library listing.
|
||||
const downloadByGame = new Map<string, (typeof downloadRows)[number]>();
|
||||
for (const row of downloadRows) {
|
||||
const existing = downloadByGame.get(row.gameId);
|
||||
if (!existing || row.timeUpdated > existing.timeUpdated) {
|
||||
downloadByGame.set(row.gameId, row);
|
||||
}
|
||||
}
|
||||
|
||||
return rows
|
||||
.filter((row) => row.game !== null)
|
||||
.map((row) => {
|
||||
const download = downloadByGame.get(row.library.gameId);
|
||||
return {
|
||||
id: row.library.id,
|
||||
game: Game.serialize(row.game!),
|
||||
playtime2w: row.library.playtime2w,
|
||||
playtimeForever: row.library.playtimeForever,
|
||||
lastPlayed: row.library.lastPlayed?.toISOString() ?? null,
|
||||
download: download ? GameDownload.serialize(download) : null
|
||||
};
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export const listByGame = fn(Info.shape.gameId, async (gameId) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(UserLibraryTable)
|
||||
.where(and(eq(UserLibraryTable.gameId, gameId), isNull(UserLibraryTable.timeDeleted)));
|
||||
});
|
||||
});
|
||||
|
||||
export const listByUserAndGameIDs = fn(
|
||||
z.object({ userId: z.string(), gameIds: z.array(z.string()) }),
|
||||
async (input) => {
|
||||
if (input.gameIds.length === 0) return [];
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(UserLibraryTable)
|
||||
.where(
|
||||
and(
|
||||
eq(UserLibraryTable.userId, input.userId),
|
||||
inArray(UserLibraryTable.gameId, input.gameIds),
|
||||
isNull(UserLibraryTable.timeDeleted)
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const remove = fn(Info.shape.id, async (id) => {
|
||||
await Database.use(async (tx) => {
|
||||
await tx
|
||||
.update(UserLibraryTable)
|
||||
.set({ timeDeleted: sql`now()` })
|
||||
.where(eq(UserLibraryTable.id, id));
|
||||
});
|
||||
});
|
||||
|
||||
export function serialize(input: typeof UserLibraryTable.$inferSelect): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
userId: input.userId,
|
||||
gameId: input.gameId,
|
||||
playtime2w: input.playtime2w,
|
||||
playtimeForever: input.playtimeForever,
|
||||
lastPlayed: input.lastPlayed?.toISOString() ?? null
|
||||
};
|
||||
}
|
||||
}
|
||||
24
packages/core/src/user/linked-account.sql.ts
Normal file
24
packages/core/src/user/linked-account.sql.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { index, jsonb, pgEnum, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { id, timestamps, ulid } from '../db/types.js';
|
||||
import { UserTable } from '../user/user.sql.js';
|
||||
|
||||
export const ProviderEnum = pgEnum('linked_account_provider', ['steam', 'ssh', 'discord']);
|
||||
|
||||
export const LinkedAccountTable = pgTable(
|
||||
'linked_account',
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
userId: ulid('user_id')
|
||||
.notNull()
|
||||
.references(() => UserTable.id, { onDelete: 'cascade' }),
|
||||
provider: ProviderEnum('provider').notNull(),
|
||||
providerAccountId: text('provider_account_id').notNull(),
|
||||
profile: jsonb('profile').$type<{}>()
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('linked_account_provider_unique').on(t.provider, t.providerAccountId),
|
||||
index('linked_account_user_idx').on(t.userId)
|
||||
]
|
||||
);
|
||||
167
packages/core/src/user/linked-account.ts
Normal file
167
packages/core/src/user/linked-account.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { eq, and, isNull, sql } from 'drizzle-orm';
|
||||
import z from 'zod';
|
||||
|
||||
import { Database } from '../db/index.js';
|
||||
import { Examples } from '../examples.js';
|
||||
import { fn } from '../fn.js';
|
||||
import { LinkedAccountTable, ProviderEnum } from './linked-account.sql.js';
|
||||
|
||||
export namespace LinkedAccount {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().meta({
|
||||
description: 'Unique identifier for the linked account record',
|
||||
example: Examples.LinkedAccount.id
|
||||
}),
|
||||
userId: z.string().meta({
|
||||
description: 'The user this account belongs to',
|
||||
example: Examples.LinkedAccount.userId
|
||||
}),
|
||||
provider: z.enum(ProviderEnum.enumValues).meta({
|
||||
description: 'Authentication provider',
|
||||
example: Examples.LinkedAccount.provider
|
||||
}),
|
||||
providerAccountId: z.string().meta({
|
||||
description: 'The account ID from the provider',
|
||||
example: Examples.LinkedAccount.providerAccountId
|
||||
}),
|
||||
profile: z.record(z.string(), z.unknown()).nullable().optional().meta({
|
||||
description: 'Platform-specific profile data (name, avatar, etc.)',
|
||||
example: Examples.LinkedAccount.profile
|
||||
})
|
||||
})
|
||||
.meta({
|
||||
ref: 'LinkedAccount',
|
||||
description: 'A linked gaming or OAuth identity (Steam, Epic Games, GitHub, Discord, etc.)',
|
||||
example: Examples.LinkedAccount
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const create = fn(Info, async (input) => {
|
||||
await Database.use(async (tx) => {
|
||||
await tx.insert(LinkedAccountTable).values({
|
||||
id: input.id,
|
||||
userId: input.userId,
|
||||
provider: input.provider,
|
||||
providerAccountId: input.providerAccountId,
|
||||
profile: input.profile ?? null
|
||||
});
|
||||
});
|
||||
return input.id;
|
||||
});
|
||||
|
||||
export const fromID = fn(Info.shape.id, async (id) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(LinkedAccountTable)
|
||||
.where(and(eq(LinkedAccountTable.id, id), isNull(LinkedAccountTable.timeDeleted)))
|
||||
.then((rows) => rows.at(0) ?? null);
|
||||
});
|
||||
});
|
||||
|
||||
export const findByProvider = fn(
|
||||
Info.pick({ provider: true, providerAccountId: true }),
|
||||
async (input) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(LinkedAccountTable)
|
||||
.where(
|
||||
and(
|
||||
eq(LinkedAccountTable.provider, input.provider),
|
||||
eq(LinkedAccountTable.providerAccountId, input.providerAccountId),
|
||||
isNull(LinkedAccountTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.then((rows) => rows.at(0) ?? null);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const findSshByFingerprint = fn(Info.shape.providerAccountId, async (fingerprint) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(LinkedAccountTable)
|
||||
.where(
|
||||
and(
|
||||
eq(LinkedAccountTable.provider, 'ssh'),
|
||||
eq(LinkedAccountTable.providerAccountId, fingerprint),
|
||||
isNull(LinkedAccountTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.then((rows) => rows.at(0) ?? null);
|
||||
});
|
||||
});
|
||||
|
||||
export const findSteamByUser = fn(Info.shape.userId, async (userId) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(LinkedAccountTable)
|
||||
.where(
|
||||
and(
|
||||
eq(LinkedAccountTable.userId, userId),
|
||||
eq(LinkedAccountTable.provider, 'steam'),
|
||||
isNull(LinkedAccountTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.then((rows) => rows.at(0) ?? null);
|
||||
});
|
||||
});
|
||||
|
||||
export const repoint = fn(Info.pick({ id: true, userId: true }), async (input) => {
|
||||
await Database.use(async (tx) => {
|
||||
await tx
|
||||
.update(LinkedAccountTable)
|
||||
.set({
|
||||
userId: input.userId,
|
||||
timeUpdated: sql`now()`
|
||||
})
|
||||
.where(and(eq(LinkedAccountTable.id, input.id), isNull(LinkedAccountTable.timeDeleted)));
|
||||
});
|
||||
});
|
||||
|
||||
export const updateProfile = fn(Info.pick({ id: true, profile: true }), async (input) => {
|
||||
await Database.use(async (tx) => {
|
||||
await tx
|
||||
.update(LinkedAccountTable)
|
||||
.set({
|
||||
profile: input.profile ?? null,
|
||||
timeUpdated: sql`now()`
|
||||
})
|
||||
.where(and(eq(LinkedAccountTable.id, input.id), isNull(LinkedAccountTable.timeDeleted)));
|
||||
});
|
||||
});
|
||||
|
||||
export const listByUser = fn(Info.shape.userId, async (userId) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(LinkedAccountTable)
|
||||
.where(and(eq(LinkedAccountTable.userId, userId), isNull(LinkedAccountTable.timeDeleted)))
|
||||
.orderBy(LinkedAccountTable.timeCreated);
|
||||
});
|
||||
});
|
||||
|
||||
export const remove = fn(Info.shape.id, async (id) => {
|
||||
await Database.use(async (tx) => {
|
||||
await tx
|
||||
.update(LinkedAccountTable)
|
||||
.set({ timeDeleted: sql`now()` })
|
||||
.where(eq(LinkedAccountTable.id, id));
|
||||
});
|
||||
});
|
||||
|
||||
export function serialize(input: typeof LinkedAccountTable.$inferSelect): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
userId: input.userId,
|
||||
provider: input.provider,
|
||||
providerAccountId: input.providerAccountId,
|
||||
profile: input.profile
|
||||
};
|
||||
}
|
||||
}
|
||||
12
packages/core/src/user/user.sql.ts
Normal file
12
packages/core/src/user/user.sql.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { boolean, pgTable, text } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { id, timestamps } from '../db/types.js';
|
||||
|
||||
export const UserTable = pgTable('user', {
|
||||
...id,
|
||||
...timestamps,
|
||||
name: text('name').notNull(),
|
||||
email: text('email'),
|
||||
emailVerified: boolean('email_verified').notNull().default(false),
|
||||
image: text('image')
|
||||
});
|
||||
Reference in New Issue
Block a user