mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 08:45:38 +02:00
chore: Migrate to namespace
This commit is contained in:
@@ -5,158 +5,165 @@ import { Examples } from "../examples";
|
|||||||
import { createEvent } from "../event";
|
import { createEvent } from "../event";
|
||||||
import { eq, isNull, and } from "drizzle-orm";
|
import { eq, isNull, and } from "drizzle-orm";
|
||||||
import { ImageTypeEnum } from "../images/images.sql";
|
import { ImageTypeEnum } from "../images/images.sql";
|
||||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
import { Database } from "../drizzle";
|
||||||
import { CompatibilityEnum, baseGamesTable, Size, ControllerEnum, Links } from "./base-game.sql";
|
import {
|
||||||
|
CompatibilityEnum,
|
||||||
|
baseGamesTable,
|
||||||
|
Size,
|
||||||
|
ControllerEnum,
|
||||||
|
Links,
|
||||||
|
} from "./base-game.sql";
|
||||||
|
|
||||||
export namespace BaseGame {
|
export namespace BaseGame {
|
||||||
export const Info = z.object({
|
export const Info = z
|
||||||
id: z.string().openapi({
|
.object({
|
||||||
description: Common.IdDescription,
|
id: z.string().openapi({
|
||||||
example: Examples.BaseGame.id
|
description: Common.IdDescription,
|
||||||
}),
|
example: Examples.BaseGame.id,
|
||||||
slug: z.string().openapi({
|
}),
|
||||||
description: "A URL-friendly unique identifier for the game, used in web addresses and API endpoints",
|
slug: z.string().openapi({
|
||||||
example: Examples.BaseGame.slug
|
description:
|
||||||
}),
|
"A URL-friendly unique identifier for the game, used in web addresses and API endpoints",
|
||||||
name: z.string().openapi({
|
example: Examples.BaseGame.slug,
|
||||||
description: "The official title of the game as listed on Steam",
|
}),
|
||||||
example: Examples.BaseGame.name
|
name: z.string().openapi({
|
||||||
}),
|
description: "The official title of the game as listed on Steam",
|
||||||
size: Size.openapi({
|
example: Examples.BaseGame.name,
|
||||||
description: "Storage requirements in bytes: downloadSize represents the compressed download, and sizeOnDisk represents the installed size",
|
}),
|
||||||
example: Examples.BaseGame.size
|
size: Size.openapi({
|
||||||
}),
|
description:
|
||||||
releaseDate: z.date().openapi({
|
"Storage requirements in bytes: downloadSize represents the compressed download, and sizeOnDisk represents the installed size",
|
||||||
description: "The initial public release date of the game on Steam",
|
example: Examples.BaseGame.size,
|
||||||
example: Examples.BaseGame.releaseDate
|
}),
|
||||||
}),
|
releaseDate: z.date().openapi({
|
||||||
description: z.string().nullable().openapi({
|
description: "The initial public release date of the game on Steam",
|
||||||
description: "A comprehensive overview of the game, including its features, storyline, and gameplay elements",
|
example: Examples.BaseGame.releaseDate,
|
||||||
example: Examples.BaseGame.description
|
}),
|
||||||
}),
|
description: z.string().nullable().openapi({
|
||||||
score: z.number().openapi({
|
description:
|
||||||
description: "The aggregate user review score on Steam, represented as a percentage of positive reviews",
|
"A comprehensive overview of the game, including its features, storyline, and gameplay elements",
|
||||||
example: Examples.BaseGame.score
|
example: Examples.BaseGame.description,
|
||||||
}),
|
}),
|
||||||
links: Links
|
score: z.number().openapi({
|
||||||
.nullable()
|
description:
|
||||||
.openapi({
|
"The aggregate user review score on Steam, represented as a percentage of positive reviews",
|
||||||
description: "The social links of this game",
|
example: Examples.BaseGame.score,
|
||||||
example: Examples.BaseGame.links
|
}),
|
||||||
}),
|
links: Links.nullable().openapi({
|
||||||
primaryGenre: z.string().nullable().openapi({
|
description: "The social links of this game",
|
||||||
description: "The main category or genre that best represents the game's content and gameplay style",
|
example: Examples.BaseGame.links,
|
||||||
example: Examples.BaseGame.primaryGenre
|
}),
|
||||||
}),
|
primaryGenre: z.string().nullable().openapi({
|
||||||
controllerSupport: z.enum(ControllerEnum.enumValues).openapi({
|
description:
|
||||||
description: "Indicates the level of gamepad/controller compatibility: 'Full', 'Partial', or 'Unkown' for no support",
|
"The main category or genre that best represents the game's content and gameplay style",
|
||||||
example: Examples.BaseGame.controllerSupport
|
example: Examples.BaseGame.primaryGenre,
|
||||||
}),
|
}),
|
||||||
compatibility: z.enum(CompatibilityEnum.enumValues).openapi({
|
controllerSupport: z.enum(ControllerEnum.enumValues).openapi({
|
||||||
description: "Steam Deck/Proton compatibility rating indicating how well the game runs on Linux systems",
|
description:
|
||||||
example: Examples.BaseGame.compatibility
|
"Indicates the level of gamepad/controller compatibility: 'Full', 'Partial', or 'Unkown' for no support",
|
||||||
}),
|
example: Examples.BaseGame.controllerSupport,
|
||||||
}).openapi({
|
}),
|
||||||
ref: "BaseGame",
|
compatibility: z.enum(CompatibilityEnum.enumValues).openapi({
|
||||||
description: "Detailed information about a game available in the Nestri library, including technical specifications and metadata",
|
description:
|
||||||
example: Examples.BaseGame
|
"Steam Deck/Proton compatibility rating indicating how well the game runs on Linux systems",
|
||||||
|
example: Examples.BaseGame.compatibility,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
.openapi({
|
||||||
|
ref: "BaseGame",
|
||||||
|
description:
|
||||||
|
"Detailed information about a game available in the Nestri library, including technical specifications and metadata",
|
||||||
|
example: Examples.BaseGame,
|
||||||
|
});
|
||||||
|
|
||||||
export type Info = z.infer<typeof Info>;
|
export type Info = z.infer<typeof Info>;
|
||||||
|
|
||||||
export const Events = {
|
export const Events = {
|
||||||
New: createEvent(
|
New: createEvent(
|
||||||
"new_image.save",
|
"new_image.save",
|
||||||
z.object({
|
z.object({
|
||||||
appID: Info.shape.id,
|
appID: Info.shape.id,
|
||||||
url: z.string().url(),
|
url: z.string().url(),
|
||||||
type: z.enum(ImageTypeEnum.enumValues)
|
type: z.enum(ImageTypeEnum.enumValues),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
NewBoxArt: createEvent(
|
NewBoxArt: createEvent(
|
||||||
"new_box_art_image.save",
|
"new_box_art_image.save",
|
||||||
z.object({
|
z.object({
|
||||||
appID: Info.shape.id,
|
appID: Info.shape.id,
|
||||||
logoUrl: z.string().url(),
|
logoUrl: z.string().url(),
|
||||||
backgroundUrl: z.string().url(),
|
backgroundUrl: z.string().url(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
NewHeroArt: createEvent(
|
NewHeroArt: createEvent(
|
||||||
"new_hero_art_image.save",
|
"new_hero_art_image.save",
|
||||||
z.object({
|
z.object({
|
||||||
appID: Info.shape.id,
|
appID: Info.shape.id,
|
||||||
backdropUrl: z.string().url(),
|
backdropUrl: z.string().url(),
|
||||||
screenshots: z.string().url().array(),
|
screenshots: z.string().url().array(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const create = fn(Info, (input) =>
|
||||||
|
Database.transaction(async (tx) => {
|
||||||
|
const result = await tx
|
||||||
|
.select()
|
||||||
|
.from(baseGamesTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(baseGamesTable.id, input.id),
|
||||||
|
isNull(baseGamesTable.timeDeleted),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
.execute()
|
||||||
|
.then((rows) => rows.at(0));
|
||||||
|
|
||||||
|
if (result) return result.id;
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.insert(baseGamesTable)
|
||||||
|
.values(input)
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: baseGamesTable.id,
|
||||||
|
set: {
|
||||||
|
timeDeleted: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return input.id;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const fromID = fn(Info.shape.id, (id) =>
|
||||||
|
Database.transaction(async (tx) =>
|
||||||
|
tx
|
||||||
|
.select()
|
||||||
|
.from(baseGamesTable)
|
||||||
|
.where(
|
||||||
|
and(eq(baseGamesTable.id, id), isNull(baseGamesTable.timeDeleted)),
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
.then((rows) => rows.map(serialize).at(0)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function serialize(
|
||||||
|
input: typeof baseGamesTable.$inferSelect,
|
||||||
|
): z.infer<typeof Info> {
|
||||||
|
return {
|
||||||
|
id: input.id,
|
||||||
|
name: input.name,
|
||||||
|
slug: input.slug,
|
||||||
|
size: input.size,
|
||||||
|
links: input.links,
|
||||||
|
score: input.score,
|
||||||
|
description: input.description,
|
||||||
|
releaseDate: input.releaseDate,
|
||||||
|
primaryGenre: input.primaryGenre,
|
||||||
|
compatibility: input.compatibility,
|
||||||
|
controllerSupport: input.controllerSupport,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
export const create = fn(
|
}
|
||||||
Info,
|
|
||||||
(input) =>
|
|
||||||
createTransaction(async (tx) => {
|
|
||||||
const result = await tx
|
|
||||||
.select()
|
|
||||||
.from(baseGamesTable)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(baseGamesTable.id, input.id),
|
|
||||||
isNull(baseGamesTable.timeDeleted)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
.execute()
|
|
||||||
.then(rows => rows.at(0))
|
|
||||||
|
|
||||||
if (result) return result.id
|
|
||||||
|
|
||||||
await tx
|
|
||||||
.insert(baseGamesTable)
|
|
||||||
.values(input)
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: baseGamesTable.id,
|
|
||||||
set: {
|
|
||||||
timeDeleted: null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return input.id
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
export const fromID = fn(
|
|
||||||
Info.shape.id,
|
|
||||||
(id) =>
|
|
||||||
useTransaction(async (tx) =>
|
|
||||||
tx
|
|
||||||
.select()
|
|
||||||
.from(baseGamesTable)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(baseGamesTable.id, id),
|
|
||||||
isNull(baseGamesTable.timeDeleted)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
.then(rows => rows.map(serialize).at(0))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
export function serialize(
|
|
||||||
input: typeof baseGamesTable.$inferSelect,
|
|
||||||
): z.infer<typeof Info> {
|
|
||||||
return {
|
|
||||||
id: input.id,
|
|
||||||
name: input.name,
|
|
||||||
slug: input.slug,
|
|
||||||
size: input.size,
|
|
||||||
links: input.links,
|
|
||||||
score: input.score,
|
|
||||||
description: input.description,
|
|
||||||
releaseDate: input.releaseDate,
|
|
||||||
primaryGenre: input.primaryGenre,
|
|
||||||
compatibility: input.compatibility,
|
|
||||||
controllerSupport: input.controllerSupport,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,128 +1,140 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fn } from "../utils";
|
import { fn } from "../utils";
|
||||||
|
import { Database } from "../drizzle";
|
||||||
import { Examples } from "../examples";
|
import { Examples } from "../examples";
|
||||||
import { eq, isNull, and } from "drizzle-orm";
|
import { eq, isNull, and } from "drizzle-orm";
|
||||||
import { createSelectSchema } from "drizzle-zod";
|
import { createSelectSchema } from "drizzle-zod";
|
||||||
import { categoriesTable } from "./categories.sql";
|
import { categoriesTable } from "./categories.sql";
|
||||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
|
||||||
|
|
||||||
export namespace Categories {
|
export namespace Categories {
|
||||||
|
const Category = z.object({
|
||||||
|
slug: z.string().openapi({
|
||||||
|
description: "A URL-friendly unique identifier for the category",
|
||||||
|
example: "action-adventure",
|
||||||
|
}),
|
||||||
|
name: z.string().openapi({
|
||||||
|
description: "The human-readable display name of the category",
|
||||||
|
example: "Action Adventure",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const Category = z.object({
|
export const Info = z
|
||||||
slug: z.string().openapi({
|
.object({
|
||||||
description: "A URL-friendly unique identifier for the category",
|
publishers: Category.array().openapi({
|
||||||
example: "action-adventure"
|
description:
|
||||||
}),
|
"List of companies or organizations responsible for publishing and distributing the game",
|
||||||
name: z.string().openapi({
|
example: Examples.Categories.publishers,
|
||||||
description: "The human-readable display name of the category",
|
}),
|
||||||
example: "Action Adventure"
|
developers: Category.array().openapi({
|
||||||
})
|
description:
|
||||||
|
"List of studios, teams, or individuals who created and developed the game",
|
||||||
|
example: Examples.Categories.developers,
|
||||||
|
}),
|
||||||
|
tags: Category.array().openapi({
|
||||||
|
description:
|
||||||
|
"User-defined labels that describe specific features, themes, or characteristics of the game",
|
||||||
|
example: Examples.Categories.tags,
|
||||||
|
}),
|
||||||
|
genres: Category.array().openapi({
|
||||||
|
description:
|
||||||
|
"Primary classification categories that define the game's style and type of gameplay",
|
||||||
|
example: Examples.Categories.genres,
|
||||||
|
}),
|
||||||
|
categories: Category.array().openapi({
|
||||||
|
description:
|
||||||
|
"Primary classification categories that define the game's categorisation on Steam",
|
||||||
|
example: Examples.Categories.genres,
|
||||||
|
}),
|
||||||
|
franchises: Category.array().openapi({
|
||||||
|
description: "The franchise this game belongs belongs to on Steam",
|
||||||
|
example: Examples.Categories.genres,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
.openapi({
|
||||||
|
ref: "Categories",
|
||||||
|
description:
|
||||||
|
"A comprehensive categorization system for games, including publishing details, development credits, and content classification",
|
||||||
|
example: Examples.Categories,
|
||||||
|
});
|
||||||
|
|
||||||
export const Info =
|
export type Info = z.infer<typeof Info>;
|
||||||
z.object({
|
|
||||||
publishers: Category.array().openapi({
|
|
||||||
description: "List of companies or organizations responsible for publishing and distributing the game",
|
|
||||||
example: Examples.Categories.publishers
|
|
||||||
}),
|
|
||||||
developers: Category.array().openapi({
|
|
||||||
description: "List of studios, teams, or individuals who created and developed the game",
|
|
||||||
example: Examples.Categories.developers
|
|
||||||
}),
|
|
||||||
tags: Category.array().openapi({
|
|
||||||
description: "User-defined labels that describe specific features, themes, or characteristics of the game",
|
|
||||||
example: Examples.Categories.tags
|
|
||||||
}),
|
|
||||||
genres: Category.array().openapi({
|
|
||||||
description: "Primary classification categories that define the game's style and type of gameplay",
|
|
||||||
example: Examples.Categories.genres
|
|
||||||
}),
|
|
||||||
categories: Category.array().openapi({
|
|
||||||
description: "Primary classification categories that define the game's categorisation on Steam",
|
|
||||||
example: Examples.Categories.genres
|
|
||||||
}),
|
|
||||||
franchises: Category.array().openapi({
|
|
||||||
description: "The franchise this game belongs belongs to on Steam",
|
|
||||||
example: Examples.Categories.genres
|
|
||||||
}),
|
|
||||||
|
|
||||||
}).openapi({
|
export const InputInfo = createSelectSchema(categoriesTable).omit({
|
||||||
ref: "Categories",
|
timeCreated: true,
|
||||||
description: "A comprehensive categorization system for games, including publishing details, development credits, and content classification",
|
timeDeleted: true,
|
||||||
example: Examples.Categories
|
timeUpdated: true,
|
||||||
})
|
});
|
||||||
|
|
||||||
export type Info = z.infer<typeof Info>;
|
export const create = fn(InputInfo, (input) =>
|
||||||
|
Database.transaction(async (tx) => {
|
||||||
|
const result = await tx
|
||||||
|
.select()
|
||||||
|
.from(categoriesTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(categoriesTable.slug, input.slug),
|
||||||
|
eq(categoriesTable.type, input.type),
|
||||||
|
isNull(categoriesTable.timeDeleted),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
.execute()
|
||||||
|
.then((rows) => rows.at(0));
|
||||||
|
|
||||||
export const InputInfo = createSelectSchema(categoriesTable)
|
if (result) return result.slug;
|
||||||
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true })
|
|
||||||
|
|
||||||
export const create = fn(
|
await tx
|
||||||
InputInfo,
|
.insert(categoriesTable)
|
||||||
(input) =>
|
.values(input)
|
||||||
createTransaction(async (tx) => {
|
.onConflictDoUpdate({
|
||||||
const result = await tx
|
target: [categoriesTable.slug, categoriesTable.type],
|
||||||
.select()
|
set: { timeDeleted: null },
|
||||||
.from(categoriesTable)
|
});
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(categoriesTable.slug, input.slug),
|
|
||||||
eq(categoriesTable.type, input.type),
|
|
||||||
isNull(categoriesTable.timeDeleted)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
.execute()
|
|
||||||
.then(rows => rows.at(0))
|
|
||||||
|
|
||||||
if (result) return result.slug
|
return input.slug;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
await tx
|
export const get = fn(InputInfo.pick({ slug: true, type: true }), (input) =>
|
||||||
.insert(categoriesTable)
|
Database.transaction((tx) =>
|
||||||
.values(input)
|
tx
|
||||||
.onConflictDoUpdate({
|
.select()
|
||||||
target: [categoriesTable.slug, categoriesTable.type],
|
.from(categoriesTable)
|
||||||
set: { timeDeleted: null }
|
.where(
|
||||||
})
|
and(
|
||||||
|
eq(categoriesTable.slug, input.slug),
|
||||||
|
eq(categoriesTable.type, input.type),
|
||||||
|
isNull(categoriesTable.timeDeleted),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
.execute()
|
||||||
|
.then((rows) => serialize(rows)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return input.slug
|
export function serialize(
|
||||||
})
|
input: (typeof categoriesTable.$inferSelect)[],
|
||||||
)
|
): z.infer<typeof Info> {
|
||||||
|
return input.reduce<
|
||||||
export const get = fn(
|
Record<
|
||||||
InputInfo.pick({ slug: true, type: true }),
|
`${(typeof categoriesTable.$inferSelect)["type"]}s`,
|
||||||
(input) =>
|
{ slug: string; name: string }[]
|
||||||
useTransaction((tx) =>
|
>
|
||||||
tx
|
>(
|
||||||
.select()
|
(acc, cat) => {
|
||||||
.from(categoriesTable)
|
const key = `${cat.type}s` as `${typeof cat.type}s`;
|
||||||
.where(
|
acc[key]!.push({ slug: cat.slug, name: cat.name });
|
||||||
and(
|
return acc;
|
||||||
eq(categoriesTable.slug, input.slug),
|
},
|
||||||
eq(categoriesTable.type, input.type),
|
{
|
||||||
isNull(categoriesTable.timeDeleted)
|
tags: [],
|
||||||
)
|
genres: [],
|
||||||
)
|
publishers: [],
|
||||||
.limit(1)
|
developers: [],
|
||||||
.execute()
|
categories: [],
|
||||||
.then(rows => serialize(rows))
|
franchises: [],
|
||||||
)
|
},
|
||||||
)
|
);
|
||||||
|
}
|
||||||
export function serialize(
|
}
|
||||||
input: typeof categoriesTable.$inferSelect[],
|
|
||||||
): z.infer<typeof Info> {
|
|
||||||
return input.reduce<Record<`${typeof categoriesTable.$inferSelect["type"]}s`, { slug: string; name: string }[]>>((acc, cat) => {
|
|
||||||
const key = `${cat.type}s` as `${typeof cat.type}s`
|
|
||||||
acc[key]!.push({ slug: cat.slug, name: cat.name })
|
|
||||||
return acc
|
|
||||||
}, {
|
|
||||||
tags: [],
|
|
||||||
genres: [],
|
|
||||||
publishers: [],
|
|
||||||
developers: [],
|
|
||||||
categories: [],
|
|
||||||
franchises: []
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { fn } from "../utils";
|
|||||||
import { User } from "../user";
|
import { User } from "../user";
|
||||||
import { Steam } from "../steam";
|
import { Steam } from "../steam";
|
||||||
import { Actor } from "../actor";
|
import { Actor } from "../actor";
|
||||||
|
import { Database } from "../drizzle";
|
||||||
import { Examples } from "../examples";
|
import { Examples } from "../examples";
|
||||||
import { friendTable } from "./friend.sql";
|
import { friendTable } from "./friend.sql";
|
||||||
import { userTable } from "../user/user.sql";
|
import { userTable } from "../user/user.sql";
|
||||||
@@ -11,180 +12,161 @@ import { createSelectSchema } from "drizzle-zod";
|
|||||||
import { and, eq, isNull, sql } from "drizzle-orm";
|
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||||
import { groupBy, map, pipe, values } from "remeda";
|
import { groupBy, map, pipe, values } from "remeda";
|
||||||
import { ErrorCodes, VisibleError } from "../error";
|
import { ErrorCodes, VisibleError } from "../error";
|
||||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
|
||||||
|
|
||||||
export namespace Friend {
|
export namespace Friend {
|
||||||
export const Info = Steam.Info
|
export const Info = Steam.Info.extend({
|
||||||
.extend({
|
user: User.Info.nullable().openapi({
|
||||||
user: User.Info.nullable().openapi({
|
description: "The user account that owns this Steam account",
|
||||||
description: "The user account that owns this Steam account",
|
example: Examples.User,
|
||||||
example: Examples.User
|
}),
|
||||||
})
|
}).openapi({
|
||||||
|
ref: "Friend",
|
||||||
|
description: "Represents a friend's information stored on Nestri",
|
||||||
|
example: Examples.Friend,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const InputInfo = createSelectSchema(friendTable).omit({
|
||||||
|
timeCreated: true,
|
||||||
|
timeDeleted: true,
|
||||||
|
timeUpdated: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Info = z.infer<typeof Info>;
|
||||||
|
export type InputInfo = z.infer<typeof InputInfo>;
|
||||||
|
|
||||||
|
export const add = fn(InputInfo.partial({ steamID: true }), async (input) =>
|
||||||
|
Database.transaction(async (tx) => {
|
||||||
|
const steamID = input.steamID ?? Actor.steamID();
|
||||||
|
if (steamID === input.friendSteamID) {
|
||||||
|
throw new VisibleError(
|
||||||
|
"forbidden",
|
||||||
|
ErrorCodes.Validation.INVALID_PARAMETER,
|
||||||
|
"Cannot add yourself as a friend",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await tx
|
||||||
|
.select()
|
||||||
|
.from(friendTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(friendTable.steamID, steamID),
|
||||||
|
eq(friendTable.friendSteamID, input.friendSteamID),
|
||||||
|
isNull(friendTable.timeDeleted),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
if (results.length > 0) return null;
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.insert(friendTable)
|
||||||
|
.values({
|
||||||
|
steamID,
|
||||||
|
friendSteamID: input.friendSteamID,
|
||||||
})
|
})
|
||||||
.openapi({
|
.onConflictDoUpdate({
|
||||||
ref: "Friend",
|
target: [friendTable.steamID, friendTable.friendSteamID],
|
||||||
description: "Represents a friend's information stored on Nestri",
|
set: { timeDeleted: null },
|
||||||
example: Examples.Friend,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return steamID;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export const InputInfo = createSelectSchema(friendTable)
|
export const end = fn(InputInfo, (input) =>
|
||||||
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true })
|
Database.transaction(async (tx) =>
|
||||||
|
tx
|
||||||
|
.update(friendTable)
|
||||||
|
.set({ timeDeleted: sql`now()` })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(friendTable.steamID, input.steamID),
|
||||||
|
eq(friendTable.friendSteamID, input.friendSteamID),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
export type Info = z.infer<typeof Info>;
|
export const list = () =>
|
||||||
export type InputInfo = z.infer<typeof InputInfo>;
|
Database.transaction(async (tx) =>
|
||||||
|
tx
|
||||||
export const add = fn(
|
.select({
|
||||||
InputInfo.partial({ steamID: true }),
|
steam: steamTable,
|
||||||
async (input) =>
|
user: userTable,
|
||||||
createTransaction(async (tx) => {
|
})
|
||||||
const steamID = input.steamID ?? Actor.steamID()
|
.from(friendTable)
|
||||||
if (steamID === input.friendSteamID) {
|
.innerJoin(steamTable, eq(friendTable.friendSteamID, steamTable.id))
|
||||||
throw new VisibleError(
|
.leftJoin(userTable, eq(steamTable.userID, userTable.id))
|
||||||
"forbidden",
|
.where(
|
||||||
ErrorCodes.Validation.INVALID_PARAMETER,
|
and(
|
||||||
"Cannot add yourself as a friend"
|
eq(friendTable.steamID, Actor.steamID()),
|
||||||
);
|
isNull(friendTable.timeDeleted),
|
||||||
}
|
),
|
||||||
|
|
||||||
const results =
|
|
||||||
await tx
|
|
||||||
.select()
|
|
||||||
.from(friendTable)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(friendTable.steamID, steamID),
|
|
||||||
eq(friendTable.friendSteamID, input.friendSteamID),
|
|
||||||
isNull(friendTable.timeDeleted)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.execute()
|
|
||||||
|
|
||||||
if (results.length > 0) return null
|
|
||||||
|
|
||||||
await tx
|
|
||||||
.insert(friendTable)
|
|
||||||
.values({
|
|
||||||
steamID,
|
|
||||||
friendSteamID: input.friendSteamID
|
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: [friendTable.steamID, friendTable.friendSteamID],
|
|
||||||
set: { timeDeleted: null }
|
|
||||||
})
|
|
||||||
|
|
||||||
return steamID
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const end = fn(
|
|
||||||
InputInfo,
|
|
||||||
(input) =>
|
|
||||||
useTransaction(async (tx) =>
|
|
||||||
tx
|
|
||||||
.update(friendTable)
|
|
||||||
.set({ timeDeleted: sql`now()` })
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(friendTable.steamID, input.steamID),
|
|
||||||
eq(friendTable.friendSteamID, input.friendSteamID),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
export const list = () =>
|
|
||||||
useTransaction(async (tx) =>
|
|
||||||
tx
|
|
||||||
.select({
|
|
||||||
steam: steamTable,
|
|
||||||
user: userTable,
|
|
||||||
})
|
|
||||||
.from(friendTable)
|
|
||||||
.innerJoin(
|
|
||||||
steamTable,
|
|
||||||
eq(friendTable.friendSteamID, steamTable.id)
|
|
||||||
)
|
|
||||||
.leftJoin(
|
|
||||||
userTable,
|
|
||||||
eq(steamTable.userID, userTable.id)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(friendTable.steamID, Actor.steamID()),
|
|
||||||
isNull(friendTable.timeDeleted)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(100)
|
|
||||||
.execute()
|
|
||||||
.then(rows => serialize(rows))
|
|
||||||
)
|
)
|
||||||
|
.limit(100)
|
||||||
|
.execute()
|
||||||
|
.then((rows) => serialize(rows)),
|
||||||
|
);
|
||||||
|
|
||||||
export const fromFriendID = fn(
|
export const fromFriendID = fn(
|
||||||
InputInfo.shape.friendSteamID,
|
InputInfo.shape.friendSteamID,
|
||||||
(friendSteamID) =>
|
(friendSteamID) =>
|
||||||
useTransaction(async (tx) =>
|
Database.transaction(async (tx) =>
|
||||||
tx
|
tx
|
||||||
.select({
|
.select({
|
||||||
steam: steamTable,
|
steam: steamTable,
|
||||||
user: userTable,
|
user: userTable,
|
||||||
})
|
})
|
||||||
.from(friendTable)
|
.from(friendTable)
|
||||||
.innerJoin(
|
.innerJoin(steamTable, eq(friendTable.friendSteamID, steamTable.id))
|
||||||
steamTable,
|
.leftJoin(userTable, eq(steamTable.userID, userTable.id))
|
||||||
eq(friendTable.friendSteamID, steamTable.id)
|
.where(
|
||||||
)
|
and(
|
||||||
.leftJoin(
|
eq(friendTable.steamID, Actor.steamID()),
|
||||||
userTable,
|
eq(friendTable.friendSteamID, friendSteamID),
|
||||||
eq(steamTable.userID, userTable.id)
|
isNull(friendTable.timeDeleted),
|
||||||
)
|
),
|
||||||
.where(
|
)
|
||||||
and(
|
.limit(1)
|
||||||
eq(friendTable.steamID, Actor.steamID()),
|
.execute()
|
||||||
eq(friendTable.friendSteamID, friendSteamID),
|
.then((rows) => serialize(rows).at(0)),
|
||||||
isNull(friendTable.timeDeleted)
|
),
|
||||||
)
|
);
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
.execute()
|
|
||||||
.then(rows => serialize(rows).at(0))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
export const areFriends = fn(InputInfo.shape.friendSteamID, (friendSteamID) =>
|
||||||
export const areFriends = fn(
|
Database.transaction(async (tx) => {
|
||||||
InputInfo.shape.friendSteamID,
|
const result = await tx
|
||||||
(friendSteamID) =>
|
.select()
|
||||||
useTransaction(async (tx) => {
|
.from(friendTable)
|
||||||
const result = await tx
|
.where(
|
||||||
.select()
|
and(
|
||||||
.from(friendTable)
|
eq(friendTable.steamID, Actor.steamID()),
|
||||||
.where(
|
eq(friendTable.friendSteamID, friendSteamID),
|
||||||
and(
|
isNull(friendTable.timeDeleted),
|
||||||
eq(friendTable.steamID, Actor.steamID()),
|
),
|
||||||
eq(friendTable.friendSteamID, friendSteamID),
|
|
||||||
isNull(friendTable.timeDeleted)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
.execute()
|
|
||||||
|
|
||||||
return result.length > 0
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
export function serialize(
|
|
||||||
input: { user: typeof userTable.$inferSelect | null; steam: typeof steamTable.$inferSelect }[],
|
|
||||||
): z.infer<typeof Info>[] {
|
|
||||||
return pipe(
|
|
||||||
input,
|
|
||||||
groupBy((row) => row.steam.id),
|
|
||||||
values(),
|
|
||||||
map((group) => ({
|
|
||||||
...Steam.serialize(group[0].steam),
|
|
||||||
user: group[0].user ? User.serialize(group[0].user!) : null
|
|
||||||
}))
|
|
||||||
)
|
)
|
||||||
}
|
.limit(1)
|
||||||
|
.execute();
|
||||||
|
|
||||||
}
|
return result.length > 0;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function serialize(
|
||||||
|
input: {
|
||||||
|
user: typeof userTable.$inferSelect | null;
|
||||||
|
steam: typeof steamTable.$inferSelect;
|
||||||
|
}[],
|
||||||
|
): z.infer<typeof Info>[] {
|
||||||
|
return pipe(
|
||||||
|
input,
|
||||||
|
groupBy((row) => row.steam.id),
|
||||||
|
values(),
|
||||||
|
map((group) => ({
|
||||||
|
...Steam.serialize(group[0].steam),
|
||||||
|
user: group[0].user ? User.serialize(group[0].user!) : null,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,119 +11,131 @@ import { imagesTable } from "../images/images.sql";
|
|||||||
import { baseGamesTable } from "../base-game/base-game.sql";
|
import { baseGamesTable } from "../base-game/base-game.sql";
|
||||||
import { groupBy, map, pipe, uniqueBy, values } from "remeda";
|
import { groupBy, map, pipe, uniqueBy, values } from "remeda";
|
||||||
import { categoriesTable } from "../categories/categories.sql";
|
import { categoriesTable } from "../categories/categories.sql";
|
||||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
import { Database } from "../drizzle";
|
||||||
|
|
||||||
export namespace Game {
|
export namespace Game {
|
||||||
export const Info = z
|
export const Info = z
|
||||||
.intersection(BaseGame.Info, Categories.Info, Images.Info)
|
.intersection(BaseGame.Info, Categories.Info, Images.Info)
|
||||||
.openapi({
|
.openapi({
|
||||||
ref: "Game",
|
ref: "Game",
|
||||||
description: "Detailed information about a game available in the Nestri library, including technical specifications, categories and metadata",
|
description:
|
||||||
example: Examples.Game
|
"Detailed information about a game available in the Nestri library, including technical specifications, categories and metadata",
|
||||||
})
|
example: Examples.Game,
|
||||||
|
});
|
||||||
|
|
||||||
export type Info = z.infer<typeof Info>;
|
export type Info = z.infer<typeof Info>;
|
||||||
|
|
||||||
export const InputInfo = createSelectSchema(gamesTable)
|
export const InputInfo = createSelectSchema(gamesTable).omit({
|
||||||
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true })
|
timeCreated: true,
|
||||||
|
timeDeleted: true,
|
||||||
|
timeUpdated: true,
|
||||||
|
});
|
||||||
|
|
||||||
export const create = fn(
|
export const create = fn(InputInfo, (input) =>
|
||||||
InputInfo,
|
Database.transaction(async (tx) => {
|
||||||
(input) =>
|
const result = await tx
|
||||||
createTransaction(async (tx) => {
|
.select()
|
||||||
const result =
|
.from(gamesTable)
|
||||||
await tx
|
.where(
|
||||||
.select()
|
and(
|
||||||
.from(gamesTable)
|
eq(gamesTable.categorySlug, input.categorySlug),
|
||||||
.where(
|
eq(gamesTable.categoryType, input.categoryType),
|
||||||
and(
|
eq(gamesTable.baseGameID, input.baseGameID),
|
||||||
eq(gamesTable.categorySlug, input.categorySlug),
|
isNull(gamesTable.timeDeleted),
|
||||||
eq(gamesTable.categoryType, input.categoryType),
|
),
|
||||||
eq(gamesTable.baseGameID, input.baseGameID),
|
|
||||||
isNull(gamesTable.timeDeleted)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
.execute()
|
|
||||||
.then(rows => rows.at(0))
|
|
||||||
|
|
||||||
if (result) return result.baseGameID
|
|
||||||
|
|
||||||
await tx
|
|
||||||
.insert(gamesTable)
|
|
||||||
.values(input)
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: [gamesTable.categorySlug, gamesTable.categoryType, gamesTable.baseGameID],
|
|
||||||
set: { timeDeleted: null }
|
|
||||||
})
|
|
||||||
|
|
||||||
return input.baseGameID
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
export const fromID = fn(
|
|
||||||
InputInfo.shape.baseGameID,
|
|
||||||
(gameID) =>
|
|
||||||
useTransaction(async (tx) =>
|
|
||||||
tx
|
|
||||||
.select({
|
|
||||||
games: baseGamesTable,
|
|
||||||
categories: categoriesTable,
|
|
||||||
images: imagesTable
|
|
||||||
})
|
|
||||||
.from(gamesTable)
|
|
||||||
.innerJoin(baseGamesTable,
|
|
||||||
eq(baseGamesTable.id, gamesTable.baseGameID)
|
|
||||||
)
|
|
||||||
.leftJoin(categoriesTable,
|
|
||||||
and(
|
|
||||||
eq(categoriesTable.slug, gamesTable.categorySlug),
|
|
||||||
eq(categoriesTable.type, gamesTable.categoryType),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.leftJoin(imagesTable,
|
|
||||||
and(
|
|
||||||
eq(imagesTable.baseGameID, gamesTable.baseGameID),
|
|
||||||
isNull(imagesTable.timeDeleted),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(gamesTable.baseGameID, gameID),
|
|
||||||
isNull(gamesTable.timeDeleted)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.execute()
|
|
||||||
.then((rows) => serialize(rows).at(0))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
export function serialize(
|
|
||||||
input: { games: typeof baseGamesTable.$inferSelect; categories: typeof categoriesTable.$inferSelect | null; images: typeof imagesTable.$inferSelect | null }[],
|
|
||||||
): z.infer<typeof Info>[] {
|
|
||||||
return pipe(
|
|
||||||
input,
|
|
||||||
groupBy((row) => row.games.id),
|
|
||||||
values(),
|
|
||||||
map((group) => {
|
|
||||||
const game = BaseGame.serialize(group[0].games)
|
|
||||||
const cats = uniqueBy(
|
|
||||||
group.map(r => r.categories).filter((c): c is typeof categoriesTable.$inferSelect => Boolean(c)),
|
|
||||||
(c) => `${c.slug}:${c.type}`
|
|
||||||
)
|
|
||||||
const imgs = uniqueBy(
|
|
||||||
group.map(r => r.images).filter((c): c is typeof imagesTable.$inferSelect => Boolean(c)),
|
|
||||||
(c) => `${c.type}:${c.imageHash}:${c.position}`
|
|
||||||
)
|
|
||||||
const byType = Categories.serialize(cats)
|
|
||||||
const byImg = Images.serialize(imgs)
|
|
||||||
return {
|
|
||||||
...game,
|
|
||||||
...byType,
|
|
||||||
...byImg
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
}
|
.limit(1)
|
||||||
|
.execute()
|
||||||
|
.then((rows) => rows.at(0));
|
||||||
|
|
||||||
}
|
if (result) return result.baseGameID;
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.insert(gamesTable)
|
||||||
|
.values(input)
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [
|
||||||
|
gamesTable.categorySlug,
|
||||||
|
gamesTable.categoryType,
|
||||||
|
gamesTable.baseGameID,
|
||||||
|
],
|
||||||
|
set: { timeDeleted: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
return input.baseGameID;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const fromID = fn(InputInfo.shape.baseGameID, (gameID) =>
|
||||||
|
Database.transaction(async (tx) =>
|
||||||
|
tx
|
||||||
|
.select({
|
||||||
|
games: baseGamesTable,
|
||||||
|
categories: categoriesTable,
|
||||||
|
images: imagesTable,
|
||||||
|
})
|
||||||
|
.from(gamesTable)
|
||||||
|
.innerJoin(baseGamesTable, eq(baseGamesTable.id, gamesTable.baseGameID))
|
||||||
|
.leftJoin(
|
||||||
|
categoriesTable,
|
||||||
|
and(
|
||||||
|
eq(categoriesTable.slug, gamesTable.categorySlug),
|
||||||
|
eq(categoriesTable.type, gamesTable.categoryType),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
imagesTable,
|
||||||
|
and(
|
||||||
|
eq(imagesTable.baseGameID, gamesTable.baseGameID),
|
||||||
|
isNull(imagesTable.timeDeleted),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(gamesTable.baseGameID, gameID),
|
||||||
|
isNull(gamesTable.timeDeleted),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
|
.then((rows) => serialize(rows).at(0)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function serialize(
|
||||||
|
input: {
|
||||||
|
games: typeof baseGamesTable.$inferSelect;
|
||||||
|
categories: typeof categoriesTable.$inferSelect | null;
|
||||||
|
images: typeof imagesTable.$inferSelect | null;
|
||||||
|
}[],
|
||||||
|
): z.infer<typeof Info>[] {
|
||||||
|
return pipe(
|
||||||
|
input,
|
||||||
|
groupBy((row) => row.games.id),
|
||||||
|
values(),
|
||||||
|
map((group) => {
|
||||||
|
const game = BaseGame.serialize(group[0].games);
|
||||||
|
const cats = uniqueBy(
|
||||||
|
group
|
||||||
|
.map((r) => r.categories)
|
||||||
|
.filter((c): c is typeof categoriesTable.$inferSelect =>
|
||||||
|
Boolean(c),
|
||||||
|
),
|
||||||
|
(c) => `${c.slug}:${c.type}`,
|
||||||
|
);
|
||||||
|
const imgs = uniqueBy(
|
||||||
|
group
|
||||||
|
.map((r) => r.images)
|
||||||
|
.filter((c): c is typeof imagesTable.$inferSelect => Boolean(c)),
|
||||||
|
(c) => `${c.type}:${c.imageHash}:${c.position}`,
|
||||||
|
);
|
||||||
|
const byType = Categories.serialize(cats);
|
||||||
|
const byImg = Images.serialize(imgs);
|
||||||
|
return {
|
||||||
|
...game,
|
||||||
|
...byType,
|
||||||
|
...byImg,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,119 +1,151 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fn } from "../utils";
|
import { fn } from "../utils";
|
||||||
|
import { Database } from "../drizzle";
|
||||||
import { Examples } from "../examples";
|
import { Examples } from "../examples";
|
||||||
import { createSelectSchema } from "drizzle-zod";
|
import { createSelectSchema } from "drizzle-zod";
|
||||||
import { createTransaction } from "../drizzle/transaction";
|
|
||||||
import { ImageColor, ImageDimensions, imagesTable } from "./images.sql";
|
import { ImageColor, ImageDimensions, imagesTable } from "./images.sql";
|
||||||
|
|
||||||
export namespace Images {
|
export namespace Images {
|
||||||
const Image = z.object({
|
const Image = z.object({
|
||||||
hash: z.string().openapi({
|
hash: z.string().openapi({
|
||||||
description: "A unique cryptographic hash identifier for the image, used for deduplication and URL generation",
|
description:
|
||||||
example: Examples.CommonImg[0].hash
|
"A unique cryptographic hash identifier for the image, used for deduplication and URL generation",
|
||||||
}),
|
example: Examples.CommonImg[0].hash,
|
||||||
averageColor: ImageColor.openapi({
|
}),
|
||||||
description: "The calculated dominant color of the image with light/dark classification, used for UI theming",
|
averageColor: ImageColor.openapi({
|
||||||
example: Examples.CommonImg[0].averageColor
|
description:
|
||||||
}),
|
"The calculated dominant color of the image with light/dark classification, used for UI theming",
|
||||||
dimensions: ImageDimensions.openapi({
|
example: Examples.CommonImg[0].averageColor,
|
||||||
description: "The width and height dimensions of the image in pixels",
|
}),
|
||||||
example: Examples.CommonImg[0].dimensions
|
dimensions: ImageDimensions.openapi({
|
||||||
}),
|
description: "The width and height dimensions of the image in pixels",
|
||||||
fileSize: z.number().int().openapi({
|
example: Examples.CommonImg[0].dimensions,
|
||||||
description: "The size of the image file in bytes, used for storage and bandwidth calculations",
|
}),
|
||||||
example: Examples.CommonImg[0].fileSize
|
fileSize: z.number().int().openapi({
|
||||||
})
|
description:
|
||||||
|
"The size of the image file in bytes, used for storage and bandwidth calculations",
|
||||||
|
example: Examples.CommonImg[0].fileSize,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Info = z
|
||||||
|
.object({
|
||||||
|
screenshots: Image.array().openapi({
|
||||||
|
description:
|
||||||
|
"In-game captured images showing actual gameplay, user interface, and key moments",
|
||||||
|
example: Examples.Images.screenshots,
|
||||||
|
}),
|
||||||
|
boxArts: Image.array().openapi({
|
||||||
|
description:
|
||||||
|
"Square 1:1 aspect ratio artwork, typically used for store listings and thumbnails",
|
||||||
|
example: Examples.Images.boxArts,
|
||||||
|
}),
|
||||||
|
posters: Image.array().openapi({
|
||||||
|
description:
|
||||||
|
"Vertical 2:3 aspect ratio promotional artwork, similar to movie posters",
|
||||||
|
example: Examples.Images.posters,
|
||||||
|
}),
|
||||||
|
banners: Image.array().openapi({
|
||||||
|
description:
|
||||||
|
"Horizontal promotional artwork optimized for header displays and banners",
|
||||||
|
example: Examples.Images.banners,
|
||||||
|
}),
|
||||||
|
heroArts: Image.array().openapi({
|
||||||
|
description:
|
||||||
|
"High-resolution, wide-format artwork designed for featured content and main entries",
|
||||||
|
example: Examples.Images.heroArts,
|
||||||
|
}),
|
||||||
|
backdrops: Image.array().openapi({
|
||||||
|
description:
|
||||||
|
"Full-width backdrop images optimized for page layouts and decorative purposes",
|
||||||
|
example: Examples.Images.backdrops,
|
||||||
|
}),
|
||||||
|
logos: Image.array().openapi({
|
||||||
|
description:
|
||||||
|
"Official game logo artwork, typically with transparent backgrounds for flexible placement",
|
||||||
|
example: Examples.Images.logos,
|
||||||
|
}),
|
||||||
|
icons: Image.array().openapi({
|
||||||
|
description:
|
||||||
|
"Small-format identifiers used for application shortcuts and compact displays",
|
||||||
|
example: Examples.Images.icons,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
.openapi({
|
||||||
|
ref: "Images",
|
||||||
|
description:
|
||||||
|
"Complete collection of game-related visual assets, including promotional materials, UI elements, and store assets",
|
||||||
|
example: Examples.Images,
|
||||||
|
});
|
||||||
|
|
||||||
export const Info = z.object({
|
export type Info = z.infer<typeof Info>;
|
||||||
screenshots: Image.array().openapi({
|
|
||||||
description: "In-game captured images showing actual gameplay, user interface, and key moments",
|
|
||||||
example: Examples.Images.screenshots
|
|
||||||
}),
|
|
||||||
boxArts: Image.array().openapi({
|
|
||||||
description: "Square 1:1 aspect ratio artwork, typically used for store listings and thumbnails",
|
|
||||||
example: Examples.Images.boxArts
|
|
||||||
}),
|
|
||||||
posters: Image.array().openapi({
|
|
||||||
description: "Vertical 2:3 aspect ratio promotional artwork, similar to movie posters",
|
|
||||||
example: Examples.Images.posters
|
|
||||||
}),
|
|
||||||
banners: Image.array().openapi({
|
|
||||||
description: "Horizontal promotional artwork optimized for header displays and banners",
|
|
||||||
example: Examples.Images.banners
|
|
||||||
}),
|
|
||||||
heroArts: Image.array().openapi({
|
|
||||||
description: "High-resolution, wide-format artwork designed for featured content and main entries",
|
|
||||||
example: Examples.Images.heroArts
|
|
||||||
}),
|
|
||||||
backdrops: Image.array().openapi({
|
|
||||||
description: "Full-width backdrop images optimized for page layouts and decorative purposes",
|
|
||||||
example: Examples.Images.backdrops
|
|
||||||
}),
|
|
||||||
logos: Image.array().openapi({
|
|
||||||
description: "Official game logo artwork, typically with transparent backgrounds for flexible placement",
|
|
||||||
example: Examples.Images.logos
|
|
||||||
}),
|
|
||||||
icons: Image.array().openapi({
|
|
||||||
description: "Small-format identifiers used for application shortcuts and compact displays",
|
|
||||||
example: Examples.Images.icons
|
|
||||||
}),
|
|
||||||
}).openapi({
|
|
||||||
ref: "Images",
|
|
||||||
description: "Complete collection of game-related visual assets, including promotional materials, UI elements, and store assets",
|
|
||||||
example: Examples.Images
|
|
||||||
})
|
|
||||||
|
|
||||||
export type Info = z.infer<typeof Info>
|
export const InputInfo = createSelectSchema(imagesTable).omit({
|
||||||
|
timeCreated: true,
|
||||||
|
timeDeleted: true,
|
||||||
|
timeUpdated: true,
|
||||||
|
});
|
||||||
|
|
||||||
export const InputInfo = createSelectSchema(imagesTable)
|
export const create = fn(InputInfo, (input) =>
|
||||||
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true })
|
Database.transaction(async (tx) =>
|
||||||
|
tx
|
||||||
|
.insert(imagesTable)
|
||||||
|
.values(input)
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [
|
||||||
|
imagesTable.imageHash,
|
||||||
|
imagesTable.type,
|
||||||
|
imagesTable.baseGameID,
|
||||||
|
imagesTable.position,
|
||||||
|
],
|
||||||
|
set: { timeDeleted: null },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
export const create = fn(
|
export function serialize(
|
||||||
InputInfo,
|
input: (typeof imagesTable.$inferSelect)[],
|
||||||
(input) =>
|
): z.infer<typeof Info> {
|
||||||
createTransaction(async (tx) =>
|
return input
|
||||||
tx
|
.sort((a, b) => {
|
||||||
.insert(imagesTable)
|
if (a.type === b.type) {
|
||||||
.values(input)
|
return a.position - b.position;
|
||||||
.onConflictDoUpdate({
|
}
|
||||||
target: [imagesTable.imageHash, imagesTable.type, imagesTable.baseGameID, imagesTable.position],
|
return a.type.localeCompare(b.type);
|
||||||
set: { timeDeleted: null }
|
})
|
||||||
})
|
.reduce<
|
||||||
)
|
Record<
|
||||||
)
|
`${(typeof imagesTable.$inferSelect)["type"]}s`,
|
||||||
|
{
|
||||||
export function serialize(
|
hash: string;
|
||||||
input: typeof imagesTable.$inferSelect[],
|
averageColor: ImageColor;
|
||||||
): z.infer<typeof Info> {
|
dimensions: ImageDimensions;
|
||||||
return input
|
fileSize: number;
|
||||||
.sort((a, b) => {
|
}[]
|
||||||
if (a.type === b.type) {
|
>
|
||||||
return a.position - b.position;
|
>(
|
||||||
}
|
(acc, img) => {
|
||||||
return a.type.localeCompare(b.type);
|
const key = `${img.type}s` as `${typeof img.type}s`;
|
||||||
})
|
if (Array.isArray(acc[key])) {
|
||||||
.reduce<Record<`${typeof imagesTable.$inferSelect["type"]}s`, { hash: string; averageColor: ImageColor; dimensions: ImageDimensions; fileSize: number }[]>>((acc, img) => {
|
acc[key]!.push({
|
||||||
const key = `${img.type}s` as `${typeof img.type}s`
|
hash: img.imageHash,
|
||||||
if (Array.isArray(acc[key])) {
|
averageColor: img.extractedColor,
|
||||||
acc[key]!.push({
|
dimensions: img.dimensions,
|
||||||
hash: img.imageHash,
|
fileSize: img.fileSize,
|
||||||
averageColor: img.extractedColor,
|
});
|
||||||
dimensions: img.dimensions,
|
}
|
||||||
fileSize: img.fileSize
|
return acc;
|
||||||
})
|
},
|
||||||
}
|
{
|
||||||
return acc
|
screenshots: [],
|
||||||
}, {
|
boxArts: [],
|
||||||
screenshots: [],
|
banners: [],
|
||||||
boxArts: [],
|
heroArts: [],
|
||||||
banners: [],
|
posters: [],
|
||||||
heroArts: [],
|
backdrops: [],
|
||||||
posters: [],
|
icons: [],
|
||||||
backdrops: [],
|
logos: [],
|
||||||
icons: [],
|
},
|
||||||
logos: [],
|
);
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|||||||
import { fn } from "../utils";
|
import { fn } from "../utils";
|
||||||
import { Game } from "../game";
|
import { Game } from "../game";
|
||||||
import { Actor } from "../actor";
|
import { Actor } from "../actor";
|
||||||
|
import { Database } from "../drizzle";
|
||||||
import { createEvent } from "../event";
|
import { createEvent } from "../event";
|
||||||
import { gamesTable } from "../game/game.sql";
|
import { gamesTable } from "../game/game.sql";
|
||||||
import { createSelectSchema } from "drizzle-zod";
|
import { createSelectSchema } from "drizzle-zod";
|
||||||
@@ -10,129 +11,124 @@ import { imagesTable } from "../images/images.sql";
|
|||||||
import { and, eq, isNull, sql } from "drizzle-orm";
|
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||||
import { baseGamesTable } from "../base-game/base-game.sql";
|
import { baseGamesTable } from "../base-game/base-game.sql";
|
||||||
import { categoriesTable } from "../categories/categories.sql";
|
import { categoriesTable } from "../categories/categories.sql";
|
||||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
|
||||||
|
|
||||||
export namespace Library {
|
export namespace Library {
|
||||||
export const Info = createSelectSchema(steamLibraryTable)
|
export const Info = createSelectSchema(steamLibraryTable).omit({
|
||||||
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true })
|
timeCreated: true,
|
||||||
|
timeDeleted: true,
|
||||||
|
timeUpdated: true,
|
||||||
|
});
|
||||||
|
|
||||||
export type Info = z.infer<typeof Info>;
|
export type Info = z.infer<typeof Info>;
|
||||||
|
|
||||||
export const Events = {
|
export const Events = {
|
||||||
Add: createEvent(
|
Add: createEvent(
|
||||||
"library.add",
|
"library.add",
|
||||||
z.object({
|
z.object({
|
||||||
appID: z.number(),
|
appID: z.number(),
|
||||||
lastPlayed: z.date().nullable(),
|
lastPlayed: z.date().nullable(),
|
||||||
totalPlaytime: z.number(),
|
totalPlaytime: z.number(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const add = fn(
|
export const add = fn(Info.partial({ ownerSteamID: true }), async (input) =>
|
||||||
Info.partial({ ownerSteamID: true }),
|
Database.transaction(async (tx) => {
|
||||||
async (input) =>
|
const ownerSteamID = input.ownerSteamID ?? Actor.steamID();
|
||||||
createTransaction(async (tx) => {
|
const result = await tx
|
||||||
const ownerSteamID = input.ownerSteamID ?? Actor.steamID()
|
.select()
|
||||||
const result =
|
.from(steamLibraryTable)
|
||||||
await tx
|
.where(
|
||||||
.select()
|
and(
|
||||||
.from(steamLibraryTable)
|
eq(steamLibraryTable.baseGameID, input.baseGameID),
|
||||||
.where(
|
eq(steamLibraryTable.ownerSteamID, ownerSteamID),
|
||||||
and(
|
isNull(steamLibraryTable.timeDeleted),
|
||||||
eq(steamLibraryTable.baseGameID, input.baseGameID),
|
),
|
||||||
eq(steamLibraryTable.ownerSteamID, ownerSteamID),
|
|
||||||
isNull(steamLibraryTable.timeDeleted)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
.execute()
|
|
||||||
.then(rows => rows.at(0))
|
|
||||||
|
|
||||||
if (result) return result.baseGameID
|
|
||||||
|
|
||||||
await tx
|
|
||||||
.insert(steamLibraryTable)
|
|
||||||
.values({
|
|
||||||
ownerSteamID: ownerSteamID,
|
|
||||||
baseGameID: input.baseGameID,
|
|
||||||
lastPlayed: input.lastPlayed,
|
|
||||||
totalPlaytime: input.totalPlaytime,
|
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: [steamLibraryTable.ownerSteamID, steamLibraryTable.baseGameID],
|
|
||||||
set: {
|
|
||||||
timeDeleted: null,
|
|
||||||
lastPlayed: input.lastPlayed,
|
|
||||||
totalPlaytime: input.totalPlaytime,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
export const remove = fn(
|
|
||||||
Info,
|
|
||||||
(input) =>
|
|
||||||
useTransaction(async (tx) =>
|
|
||||||
tx
|
|
||||||
.update(steamLibraryTable)
|
|
||||||
.set({ timeDeleted: sql`now()` })
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(steamLibraryTable.ownerSteamID, input.ownerSteamID),
|
|
||||||
eq(steamLibraryTable.baseGameID, input.baseGameID),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
export const list = () =>
|
|
||||||
useTransaction(async (tx) =>
|
|
||||||
tx
|
|
||||||
.select({
|
|
||||||
games: baseGamesTable,
|
|
||||||
categories: categoriesTable,
|
|
||||||
images: imagesTable
|
|
||||||
})
|
|
||||||
.from(steamLibraryTable)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(steamLibraryTable.ownerSteamID, Actor.steamID()),
|
|
||||||
isNull(steamLibraryTable.timeDeleted)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.innerJoin(
|
|
||||||
baseGamesTable,
|
|
||||||
eq(baseGamesTable.id, steamLibraryTable.baseGameID),
|
|
||||||
)
|
|
||||||
.leftJoin(
|
|
||||||
gamesTable,
|
|
||||||
eq(gamesTable.baseGameID, baseGamesTable.id),
|
|
||||||
)
|
|
||||||
.leftJoin(
|
|
||||||
categoriesTable,
|
|
||||||
and(
|
|
||||||
eq(categoriesTable.slug, gamesTable.categorySlug),
|
|
||||||
eq(categoriesTable.type, gamesTable.categoryType),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
// Joining imagesTable 1-N with gamesTable multiplies rows; the subsequent Game.serialize has to uniqueBy to undo this.
|
|
||||||
// For large libraries with many screenshots the Cartesian effect can significantly bloat the result and network payload.
|
|
||||||
// One option is to aggregate the images in SQL before joining to keep exactly one row per game:
|
|
||||||
// .leftJoin(
|
|
||||||
// sql<typeof imagesTable.$inferSelect[]>`(SELECT * FROM images WHERE base_game_id = ${gamesTable.baseGameID} AND time_deleted IS NULL ORDER BY type, position)`.as("images"),
|
|
||||||
// sql`TRUE`
|
|
||||||
// )
|
|
||||||
.leftJoin(
|
|
||||||
imagesTable,
|
|
||||||
and(
|
|
||||||
eq(imagesTable.baseGameID, gamesTable.baseGameID),
|
|
||||||
isNull(imagesTable.timeDeleted),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.execute()
|
|
||||||
.then(rows => Game.serialize(rows))
|
|
||||||
)
|
)
|
||||||
|
.limit(1)
|
||||||
|
.execute()
|
||||||
|
.then((rows) => rows.at(0));
|
||||||
|
|
||||||
}
|
if (result) return result.baseGameID;
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.insert(steamLibraryTable)
|
||||||
|
.values({
|
||||||
|
ownerSteamID: ownerSteamID,
|
||||||
|
baseGameID: input.baseGameID,
|
||||||
|
lastPlayed: input.lastPlayed,
|
||||||
|
totalPlaytime: input.totalPlaytime,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [
|
||||||
|
steamLibraryTable.ownerSteamID,
|
||||||
|
steamLibraryTable.baseGameID,
|
||||||
|
],
|
||||||
|
set: {
|
||||||
|
timeDeleted: null,
|
||||||
|
lastPlayed: input.lastPlayed,
|
||||||
|
totalPlaytime: input.totalPlaytime,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const remove = fn(Info, (input) =>
|
||||||
|
Database.transaction(async (tx) =>
|
||||||
|
tx
|
||||||
|
.update(steamLibraryTable)
|
||||||
|
.set({ timeDeleted: sql`now()` })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(steamLibraryTable.ownerSteamID, input.ownerSteamID),
|
||||||
|
eq(steamLibraryTable.baseGameID, input.baseGameID),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const list = () =>
|
||||||
|
Database.transaction(async (tx) =>
|
||||||
|
tx
|
||||||
|
.select({
|
||||||
|
games: baseGamesTable,
|
||||||
|
categories: categoriesTable,
|
||||||
|
images: imagesTable,
|
||||||
|
})
|
||||||
|
.from(steamLibraryTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(steamLibraryTable.ownerSteamID, Actor.steamID()),
|
||||||
|
isNull(steamLibraryTable.timeDeleted),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.innerJoin(
|
||||||
|
baseGamesTable,
|
||||||
|
eq(baseGamesTable.id, steamLibraryTable.baseGameID),
|
||||||
|
)
|
||||||
|
.leftJoin(gamesTable, eq(gamesTable.baseGameID, baseGamesTable.id))
|
||||||
|
.leftJoin(
|
||||||
|
categoriesTable,
|
||||||
|
and(
|
||||||
|
eq(categoriesTable.slug, gamesTable.categorySlug),
|
||||||
|
eq(categoriesTable.type, gamesTable.categoryType),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Joining imagesTable 1-N with gamesTable multiplies rows; the subsequent Game.serialize has to uniqueBy to undo this.
|
||||||
|
// For large libraries with many screenshots the Cartesian effect can significantly bloat the result and network payload.
|
||||||
|
// One option is to aggregate the images in SQL before joining to keep exactly one row per game:
|
||||||
|
// .leftJoin(
|
||||||
|
// sql<typeof imagesTable.$inferSelect[]>`(SELECT * FROM images WHERE base_game_id = ${gamesTable.baseGameID} AND time_deleted IS NULL ORDER BY type, position)`.as("images"),
|
||||||
|
// sql`TRUE`
|
||||||
|
// )
|
||||||
|
.leftJoin(
|
||||||
|
imagesTable,
|
||||||
|
and(
|
||||||
|
eq(imagesTable.baseGameID, gamesTable.baseGameID),
|
||||||
|
isNull(imagesTable.timeDeleted),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
|
.then((rows) => Game.serialize(rows)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,235 +4,232 @@ import { Resource } from "sst";
|
|||||||
import { Actor } from "../actor";
|
import { Actor } from "../actor";
|
||||||
import { bus } from "sst/aws/bus";
|
import { bus } from "sst/aws/bus";
|
||||||
import { Common } from "../common";
|
import { Common } from "../common";
|
||||||
|
import { Database } from "../drizzle";
|
||||||
import { Examples } from "../examples";
|
import { Examples } from "../examples";
|
||||||
import { createEvent } from "../event";
|
import { createEvent } from "../event";
|
||||||
import { eq, and, isNull, desc } from "drizzle-orm";
|
import { eq, and, isNull, desc } from "drizzle-orm";
|
||||||
import { steamTable, StatusEnum, Limitations } from "./steam.sql";
|
import { steamTable, StatusEnum, Limitations } from "./steam.sql";
|
||||||
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
|
|
||||||
|
|
||||||
export namespace Steam {
|
export namespace Steam {
|
||||||
export const Info = z
|
export const Info = z
|
||||||
.object({
|
.object({
|
||||||
id: z.string().openapi({
|
id: z.string().openapi({
|
||||||
description: Common.IdDescription,
|
description: Common.IdDescription,
|
||||||
example: Examples.SteamAccount.id
|
example: Examples.SteamAccount.id,
|
||||||
}),
|
}),
|
||||||
avatarHash: z.string().openapi({
|
avatarHash: z.string().openapi({
|
||||||
description: "The Steam avatar hash that this account owns",
|
description: "The Steam avatar hash that this account owns",
|
||||||
example: Examples.SteamAccount.avatarHash
|
example: Examples.SteamAccount.avatarHash,
|
||||||
}),
|
}),
|
||||||
status: z.enum(StatusEnum.enumValues).openapi({
|
status: z.enum(StatusEnum.enumValues).openapi({
|
||||||
description: "The current connection status of this Steam account",
|
description: "The current connection status of this Steam account",
|
||||||
example: Examples.SteamAccount.status
|
example: Examples.SteamAccount.status,
|
||||||
}),
|
}),
|
||||||
userID: z.string().nullable().openapi({
|
userID: z.string().nullable().openapi({
|
||||||
description: "The user id of which account owns this steam account",
|
description: "The user id of which account owns this steam account",
|
||||||
example: Examples.SteamAccount.userID
|
example: Examples.SteamAccount.userID,
|
||||||
}),
|
}),
|
||||||
profileUrl: z.string().nullable().openapi({
|
profileUrl: z.string().nullable().openapi({
|
||||||
description: "The steam community url of this account",
|
description: "The steam community url of this account",
|
||||||
example: Examples.SteamAccount.profileUrl
|
example: Examples.SteamAccount.profileUrl,
|
||||||
}),
|
}),
|
||||||
realName: z.string().nullable().openapi({
|
realName: z.string().nullable().openapi({
|
||||||
description: "The real name behind of this Steam account",
|
description: "The real name behind of this Steam account",
|
||||||
example: Examples.SteamAccount.realName
|
example: Examples.SteamAccount.realName,
|
||||||
}),
|
}),
|
||||||
name: z.string().openapi({
|
name: z.string().openapi({
|
||||||
description: "The name used by this account",
|
description: "The name used by this account",
|
||||||
example: Examples.SteamAccount.name
|
example: Examples.SteamAccount.name,
|
||||||
}),
|
}),
|
||||||
lastSyncedAt: z.date().openapi({
|
lastSyncedAt: z.date().openapi({
|
||||||
description: "The last time this account was synced to Steam",
|
description: "The last time this account was synced to Steam",
|
||||||
example: Examples.SteamAccount.lastSyncedAt
|
example: Examples.SteamAccount.lastSyncedAt,
|
||||||
}),
|
}),
|
||||||
limitations: Limitations.openapi({
|
limitations: Limitations.openapi({
|
||||||
description: "The limitations bestowed on this Steam account by Steam",
|
description: "The limitations bestowed on this Steam account by Steam",
|
||||||
example: Examples.SteamAccount.limitations
|
example: Examples.SteamAccount.limitations,
|
||||||
}),
|
}),
|
||||||
steamMemberSince: z.date().openapi({
|
steamMemberSince: z.date().openapi({
|
||||||
description: "When this Steam community account was created",
|
description: "When this Steam community account was created",
|
||||||
example: Examples.SteamAccount.steamMemberSince
|
example: Examples.SteamAccount.steamMemberSince,
|
||||||
})
|
}),
|
||||||
})
|
})
|
||||||
.openapi({
|
.openapi({
|
||||||
ref: "Steam",
|
ref: "Steam",
|
||||||
description: "Represents a steam user's information stored on Nestri",
|
description: "Represents a steam user's information stored on Nestri",
|
||||||
example: Examples.SteamAccount,
|
example: Examples.SteamAccount,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Info = z.infer<typeof Info>;
|
||||||
|
|
||||||
|
export const Events = {
|
||||||
|
Created: createEvent(
|
||||||
|
"steam_account.created",
|
||||||
|
z.object({
|
||||||
|
steamID: Info.shape.id,
|
||||||
|
userID: Info.shape.userID,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Updated: createEvent(
|
||||||
|
"steam_account.updated",
|
||||||
|
z.object({
|
||||||
|
steamID: Info.shape.id,
|
||||||
|
userID: Info.shape.userID,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const create = fn(
|
||||||
|
Info.extend({
|
||||||
|
useUser: z.boolean(),
|
||||||
|
}).partial({
|
||||||
|
userID: true,
|
||||||
|
status: true,
|
||||||
|
useUser: true,
|
||||||
|
lastSyncedAt: true,
|
||||||
|
}),
|
||||||
|
(input) =>
|
||||||
|
Database.transaction(async (tx) => {
|
||||||
|
const accounts = await tx
|
||||||
|
.select()
|
||||||
|
.from(steamTable)
|
||||||
|
.where(
|
||||||
|
and(isNull(steamTable.timeDeleted), eq(steamTable.id, input.id)),
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
|
.then((rows) => rows.map(serialize));
|
||||||
|
|
||||||
|
// Update instead of create
|
||||||
|
if (accounts.length > 0) return null;
|
||||||
|
|
||||||
|
const userID =
|
||||||
|
typeof input.userID === "string"
|
||||||
|
? input.userID
|
||||||
|
: input.useUser
|
||||||
|
? Actor.userID()
|
||||||
|
: null;
|
||||||
|
await tx.insert(steamTable).values({
|
||||||
|
userID,
|
||||||
|
id: input.id,
|
||||||
|
name: input.name,
|
||||||
|
realName: input.realName,
|
||||||
|
profileUrl: input.profileUrl,
|
||||||
|
avatarHash: input.avatarHash,
|
||||||
|
limitations: input.limitations,
|
||||||
|
status: input.status ?? "offline",
|
||||||
|
steamMemberSince: input.steamMemberSince,
|
||||||
|
lastSyncedAt: input.lastSyncedAt ?? Common.utc(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Info = z.infer<typeof Info>;
|
// await afterTx(async () =>
|
||||||
|
// bus.publish(Resource.Bus, Events.Created, { userID, steamID: input.id })
|
||||||
|
// );
|
||||||
|
|
||||||
export const Events = {
|
return input.id;
|
||||||
Created: createEvent(
|
}),
|
||||||
"steam_account.created",
|
);
|
||||||
z.object({
|
|
||||||
steamID: Info.shape.id,
|
export const updateOwner = fn(
|
||||||
userID: Info.shape.userID,
|
z
|
||||||
}),
|
.object({
|
||||||
),
|
userID: z.string(),
|
||||||
Updated: createEvent(
|
steamID: z.string(),
|
||||||
"steam_account.updated",
|
})
|
||||||
z.object({
|
.partial({
|
||||||
steamID: Info.shape.id,
|
userID: true,
|
||||||
userID: Info.shape.userID
|
}),
|
||||||
}),
|
async (input) =>
|
||||||
|
Database.transaction(async (tx) => {
|
||||||
|
const userID = input.userID ?? Actor.userID();
|
||||||
|
await tx
|
||||||
|
.update(steamTable)
|
||||||
|
.set({
|
||||||
|
userID,
|
||||||
|
})
|
||||||
|
.where(eq(steamTable.id, input.steamID));
|
||||||
|
|
||||||
|
// await afterTx(async () =>
|
||||||
|
// bus.publish(Resource.Bus, Events.Updated, { userID, steamID: input.steamID })
|
||||||
|
// );
|
||||||
|
|
||||||
|
return input.steamID;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const fromUserID = fn(z.string().min(1), (userID) =>
|
||||||
|
Database.transaction((tx) =>
|
||||||
|
tx
|
||||||
|
.select()
|
||||||
|
.from(steamTable)
|
||||||
|
.where(
|
||||||
|
and(eq(steamTable.userID, userID), isNull(steamTable.timeDeleted)),
|
||||||
)
|
)
|
||||||
};
|
.orderBy(desc(steamTable.timeCreated))
|
||||||
|
.execute()
|
||||||
|
.then((rows) => rows.map(serialize)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
export const create = fn(
|
export const confirmOwnerShip = fn(z.string().min(1), (userID) =>
|
||||||
Info
|
Database.transaction((tx) =>
|
||||||
.extend({
|
tx
|
||||||
useUser: z.boolean(),
|
.select()
|
||||||
})
|
.from(steamTable)
|
||||||
.partial({
|
.where(
|
||||||
userID: true,
|
and(
|
||||||
status: true,
|
eq(steamTable.userID, userID),
|
||||||
useUser: true,
|
eq(steamTable.id, Actor.steamID()),
|
||||||
lastSyncedAt: true
|
isNull(steamTable.timeDeleted),
|
||||||
}),
|
),
|
||||||
(input) =>
|
)
|
||||||
createTransaction(async (tx) => {
|
.orderBy(desc(steamTable.timeCreated))
|
||||||
const accounts =
|
.execute()
|
||||||
await tx
|
.then((rows) => rows.map(serialize).at(0)),
|
||||||
.select()
|
),
|
||||||
.from(steamTable)
|
);
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
isNull(steamTable.timeDeleted),
|
|
||||||
eq(steamTable.id, input.id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.execute()
|
|
||||||
.then((rows) => rows.map(serialize))
|
|
||||||
|
|
||||||
// Update instead of create
|
export const fromSteamID = fn(z.string(), (steamID) =>
|
||||||
if (accounts.length > 0) return null
|
Database.transaction((tx) =>
|
||||||
|
tx
|
||||||
|
.select()
|
||||||
|
.from(steamTable)
|
||||||
|
.where(and(eq(steamTable.id, steamID), isNull(steamTable.timeDeleted)))
|
||||||
|
.orderBy(desc(steamTable.timeCreated))
|
||||||
|
.execute()
|
||||||
|
.then((rows) => rows.map(serialize).at(0)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const userID = typeof input.userID === "string" ? input.userID : input.useUser ? Actor.userID() : null;
|
export const list = () =>
|
||||||
await tx
|
Database.transaction((tx) =>
|
||||||
.insert(steamTable)
|
tx
|
||||||
.values({
|
.select()
|
||||||
userID,
|
.from(steamTable)
|
||||||
id: input.id,
|
.where(
|
||||||
name: input.name,
|
and(
|
||||||
realName: input.realName,
|
eq(steamTable.userID, Actor.userID()),
|
||||||
profileUrl: input.profileUrl,
|
isNull(steamTable.timeDeleted),
|
||||||
avatarHash: input.avatarHash,
|
),
|
||||||
limitations: input.limitations,
|
)
|
||||||
status: input.status ?? "offline",
|
.orderBy(desc(steamTable.timeCreated))
|
||||||
steamMemberSince: input.steamMemberSince,
|
.execute()
|
||||||
lastSyncedAt: input.lastSyncedAt ?? Common.utc(),
|
.then((rows) => rows.map(serialize)),
|
||||||
})
|
|
||||||
|
|
||||||
await afterTx(async () =>
|
|
||||||
bus.publish(Resource.Bus, Events.Created, { userID, steamID: input.id })
|
|
||||||
);
|
|
||||||
|
|
||||||
return input.id
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const updateOwner = fn(
|
export function serialize(
|
||||||
z
|
input: typeof steamTable.$inferSelect,
|
||||||
.object({
|
): z.infer<typeof Info> {
|
||||||
userID: z.string(),
|
return {
|
||||||
steamID: z.string()
|
id: input.id,
|
||||||
})
|
name: input.name,
|
||||||
.partial({
|
status: input.status,
|
||||||
userID: true
|
userID: input.userID,
|
||||||
}),
|
realName: input.realName,
|
||||||
async (input) =>
|
profileUrl: input.profileUrl,
|
||||||
createTransaction(async (tx) => {
|
avatarHash: input.avatarHash,
|
||||||
const userID = input.userID ?? Actor.userID()
|
limitations: input.limitations,
|
||||||
await tx
|
lastSyncedAt: input.lastSyncedAt,
|
||||||
.update(steamTable)
|
steamMemberSince: input.steamMemberSince,
|
||||||
.set({
|
};
|
||||||
userID
|
}
|
||||||
})
|
}
|
||||||
.where(eq(steamTable.id, input.steamID));
|
|
||||||
|
|
||||||
await afterTx(async () =>
|
|
||||||
bus.publish(Resource.Bus, Events.Updated, { userID, steamID: input.steamID })
|
|
||||||
);
|
|
||||||
|
|
||||||
return input.steamID
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
export const fromUserID = fn(
|
|
||||||
z.string().min(1),
|
|
||||||
(userID) =>
|
|
||||||
useTransaction((tx) =>
|
|
||||||
tx
|
|
||||||
.select()
|
|
||||||
.from(steamTable)
|
|
||||||
.where(and(eq(steamTable.userID, userID), isNull(steamTable.timeDeleted)))
|
|
||||||
.orderBy(desc(steamTable.timeCreated))
|
|
||||||
.execute()
|
|
||||||
.then((rows) => rows.map(serialize))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
export const confirmOwnerShip = fn(
|
|
||||||
z.string().min(1),
|
|
||||||
(userID) =>
|
|
||||||
useTransaction((tx) =>
|
|
||||||
tx
|
|
||||||
.select()
|
|
||||||
.from(steamTable)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(steamTable.userID, userID),
|
|
||||||
eq(steamTable.id, Actor.steamID()),
|
|
||||||
isNull(steamTable.timeDeleted)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.orderBy(desc(steamTable.timeCreated))
|
|
||||||
.execute()
|
|
||||||
.then((rows) => rows.map(serialize).at(0))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
export const fromSteamID = fn(
|
|
||||||
z.string(),
|
|
||||||
(steamID) =>
|
|
||||||
useTransaction((tx) =>
|
|
||||||
tx
|
|
||||||
.select()
|
|
||||||
.from(steamTable)
|
|
||||||
.where(and(eq(steamTable.id, steamID), isNull(steamTable.timeDeleted)))
|
|
||||||
.orderBy(desc(steamTable.timeCreated))
|
|
||||||
.execute()
|
|
||||||
.then((rows) => rows.map(serialize).at(0))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
export const list = () =>
|
|
||||||
useTransaction((tx) =>
|
|
||||||
tx
|
|
||||||
.select()
|
|
||||||
.from(steamTable)
|
|
||||||
.where(and(eq(steamTable.userID, Actor.userID()), isNull(steamTable.timeDeleted)))
|
|
||||||
.orderBy(desc(steamTable.timeCreated))
|
|
||||||
.execute()
|
|
||||||
.then((rows) => rows.map(serialize))
|
|
||||||
)
|
|
||||||
|
|
||||||
export function serialize(
|
|
||||||
input: typeof steamTable.$inferSelect,
|
|
||||||
): z.infer<typeof Info> {
|
|
||||||
return {
|
|
||||||
id: input.id,
|
|
||||||
name: input.name,
|
|
||||||
status: input.status,
|
|
||||||
userID: input.userID,
|
|
||||||
realName: input.realName,
|
|
||||||
profileUrl: input.profileUrl,
|
|
||||||
avatarHash: input.avatarHash,
|
|
||||||
limitations: input.limitations,
|
|
||||||
lastSyncedAt: input.lastSyncedAt,
|
|
||||||
steamMemberSince: input.steamMemberSince,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user