mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-11 00:05:36 +02:00
⭐ feat: Add Games (#276)
## Description <!-- Briefly describe the purpose and scope of your changes --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced comprehensive management of game libraries, including adding, removing, and listing games in a user's Steam library. - Added new API endpoints for retrieving detailed game information by ID and listing all games in a user's library. - Enabled friend-related API endpoints to list friends and fetch friend details by SteamID. - Added category and base game data structures with validation and serialization for enriched game metadata. - Introduced ownership update functionality for Steam accounts during login. - Added new game and category linking to support detailed game metadata and categorization. - Introduced member retrieval functions for enhanced team and user management. - **Improvements** - Enhanced authentication to enforce team membership checks and provide member-level access control. - Improved Steam account ownership handling to ensure accurate user association. - Added indexes to friend relationships for optimized querying. - Refined API routing structure with added game and friend routes. - Improved friend listing queries for efficiency and data completeness. - **Bug Fixes** - Fixed formatting issues in permissions related to Steam accounts. - **Other** - Refined event handling for user account refresh based on user ID instead of email. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -3,7 +3,7 @@ import { createContext } from "./context";
|
||||
import { ErrorCodes, VisibleError } from "./error";
|
||||
|
||||
export namespace Actor {
|
||||
|
||||
|
||||
export interface User {
|
||||
type: "user";
|
||||
properties: {
|
||||
@@ -28,9 +28,11 @@ export namespace Actor {
|
||||
}
|
||||
|
||||
export interface Token {
|
||||
type: "steam";
|
||||
type: "member";
|
||||
properties: {
|
||||
userID: string;
|
||||
steamID: string;
|
||||
teamID: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,7 +54,7 @@ export namespace Actor {
|
||||
`You don't have permission to access this resource.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function steamID() {
|
||||
const actor = Context.use();
|
||||
if ("steamID" in actor.properties) return actor.properties.steamID;
|
||||
@@ -62,7 +64,7 @@ export namespace Actor {
|
||||
`You don't have permission to access this resource.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function user() {
|
||||
const actor = Context.use();
|
||||
if (actor.type == "user") return actor.properties;
|
||||
@@ -82,7 +84,7 @@ export namespace Actor {
|
||||
`You don't have permission to access this resource.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function fingerprint() {
|
||||
const actor = Context.use();
|
||||
if ("fingerprint" in actor.properties) return actor.properties.fingerprint;
|
||||
|
||||
39
packages/core/src/base-game/base-game.sql.ts
Normal file
39
packages/core/src/base-game/base-game.sql.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { z } from "zod";
|
||||
import { timestamps, utc } from "../drizzle/types";
|
||||
import { json, numeric, pgEnum, pgTable, text, unique, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const CompatibilityEnum = pgEnum("compatibility", ["high", "mid", "low", "unknown"])
|
||||
|
||||
export const Size =
|
||||
z.object({
|
||||
downloadSize: z.number().positive().int(),
|
||||
sizeOnDisk: z.number().positive().int()
|
||||
})
|
||||
|
||||
export type Size = z.infer<typeof Size>
|
||||
|
||||
export const baseGamesTable = pgTable(
|
||||
"base_games",
|
||||
{
|
||||
...timestamps,
|
||||
id: varchar("id", { length: 255 })
|
||||
.primaryKey()
|
||||
.notNull(),
|
||||
slug: varchar("slug", { length: 255 })
|
||||
.notNull(),
|
||||
name: text("name").notNull(),
|
||||
releaseDate: utc("release_date").notNull(),
|
||||
size: json("size").$type<Size>().notNull(),
|
||||
description: text("description").notNull(),
|
||||
primaryGenre: text("primary_genre").notNull(),
|
||||
controllerSupport: text("controller_support"),
|
||||
compatibility: CompatibilityEnum("compatibility").notNull().default("unknown"),
|
||||
// Score ranges from 0.0 to 5.0
|
||||
score: numeric("score", { precision: 2, scale: 1 })
|
||||
.$type<number>()
|
||||
.notNull()
|
||||
},
|
||||
(table) => [
|
||||
unique("idx_base_games_slug").on(table.slug),
|
||||
]
|
||||
)
|
||||
103
packages/core/src/base-game/index.ts
Normal file
103
packages/core/src/base-game/index.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import { createTransaction } from "../drizzle/transaction";
|
||||
import { CompatibilityEnum, baseGamesTable, Size } from "./base-game.sql";
|
||||
import { eq, isNull, or, and } from "drizzle-orm";
|
||||
|
||||
export namespace BaseGame {
|
||||
export const Info = z.object({
|
||||
id: z.string().openapi({
|
||||
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",
|
||||
example: Examples.BaseGame.slug
|
||||
}),
|
||||
name: z.string().openapi({
|
||||
description: "The official title of the game as listed on Steam",
|
||||
example: Examples.BaseGame.name
|
||||
}),
|
||||
size: Size.openapi({
|
||||
description: "Storage requirements in bytes: downloadSize represents the compressed download, and sizeOnDisk represents the installed size",
|
||||
example: Examples.BaseGame.size
|
||||
}),
|
||||
releaseDate: z.date().openapi({
|
||||
description: "The initial public release date of the game on Steam",
|
||||
example: Examples.BaseGame.releaseDate
|
||||
}),
|
||||
description: z.string().openapi({
|
||||
description: "A comprehensive overview of the game, including its features, storyline, and gameplay elements",
|
||||
example: Examples.BaseGame.description
|
||||
}),
|
||||
score: z.number().openapi({
|
||||
description: "The aggregate user review score on Steam, represented as a percentage of positive reviews",
|
||||
example: Examples.BaseGame.score
|
||||
}),
|
||||
primaryGenre: z.string().openapi({
|
||||
description: "The main category or genre that best represents the game's content and gameplay style",
|
||||
example: Examples.BaseGame.primaryGenre
|
||||
}),
|
||||
controllerSupport: z.string().nullable().openapi({
|
||||
description: "Indicates the level of gamepad/controller compatibility: 'Full', 'Partial', or null for no support",
|
||||
example: Examples.BaseGame.controllerSupport
|
||||
}),
|
||||
compatibility: z.enum(CompatibilityEnum.enumValues).openapi({
|
||||
description: "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 const create = fn(
|
||||
Info,
|
||||
(input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const results = await tx
|
||||
.select()
|
||||
.from(baseGamesTable)
|
||||
.where(
|
||||
and(
|
||||
or(
|
||||
eq(baseGamesTable.slug, input.slug),
|
||||
eq(baseGamesTable.id, input.id),
|
||||
),
|
||||
isNull(baseGamesTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.execute()
|
||||
|
||||
if (results.length > 0) return null
|
||||
|
||||
await tx
|
||||
.insert(baseGamesTable)
|
||||
.values(input)
|
||||
|
||||
return input.id
|
||||
})
|
||||
)
|
||||
|
||||
export function serialize(
|
||||
input: typeof baseGamesTable.$inferSelect,
|
||||
): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
size: input.size,
|
||||
score: input.score,
|
||||
description: input.description,
|
||||
releaseDate: input.releaseDate,
|
||||
primaryGenre: input.primaryGenre,
|
||||
compatibility: input.compatibility,
|
||||
controllerSupport: input.controllerSupport,
|
||||
};
|
||||
}
|
||||
}
|
||||
21
packages/core/src/categories/categories.sql.ts
Normal file
21
packages/core/src/categories/categories.sql.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { timestamps } from "../drizzle/types";
|
||||
import { index, pgEnum, pgTable, primaryKey, text, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const CategoryTypeEnum = pgEnum("category_type", ["tag", "genre", "publisher", "developer"])
|
||||
|
||||
export const categoriesTable = pgTable(
|
||||
"categories",
|
||||
{
|
||||
...timestamps,
|
||||
slug: varchar("slug", { length: 255 })
|
||||
.notNull(),
|
||||
type: CategoryTypeEnum("type").notNull(),
|
||||
name: text("name").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.slug, table.type]
|
||||
}),
|
||||
index("idx_categories_type").on(table.type),
|
||||
]
|
||||
)
|
||||
96
packages/core/src/categories/index.ts
Normal file
96
packages/core/src/categories/index.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Examples } from "../examples";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { categoriesTable } from "./categories.sql";
|
||||
import { createTransaction } from "../drizzle/transaction";
|
||||
import { eq, isNull, and } from "drizzle-orm";
|
||||
|
||||
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"
|
||||
})
|
||||
})
|
||||
|
||||
export const 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
|
||||
})
|
||||
}).openapi({
|
||||
ref: "Categories",
|
||||
description: "A comprehensive categorization system for games, including publishing details, development credits, and content classification",
|
||||
example: Examples.Categories
|
||||
})
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const InputInfo = createSelectSchema(categoriesTable)
|
||||
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true })
|
||||
|
||||
export const create = fn(
|
||||
InputInfo,
|
||||
(input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const results =
|
||||
await tx
|
||||
.select()
|
||||
.from(categoriesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(categoriesTable.slug, input.slug),
|
||||
eq(categoriesTable.type, input.type),
|
||||
isNull(categoriesTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.execute()
|
||||
|
||||
if (results.length > 0) return null
|
||||
|
||||
await tx
|
||||
.insert(categoriesTable)
|
||||
.values(input)
|
||||
.onConflictDoUpdate({
|
||||
target: [categoriesTable.slug, categoriesTable.type],
|
||||
set: { timeDeleted: null }
|
||||
})
|
||||
|
||||
return input.slug
|
||||
})
|
||||
)
|
||||
|
||||
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: []
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,11 @@ export namespace Examples {
|
||||
variants: [ProductVariant]
|
||||
}
|
||||
|
||||
export const Friend = {
|
||||
...Examples.SteamAccount,
|
||||
user: Examples.User
|
||||
}
|
||||
|
||||
export const Subscription = {
|
||||
id: Id("subscription"),
|
||||
teamID: Team.id,
|
||||
@@ -143,36 +148,76 @@ export namespace Examples {
|
||||
slug: "remedy_entertainment",
|
||||
}
|
||||
|
||||
export const BaseGame = {
|
||||
id: "1809540",
|
||||
slug: "nine-sols",
|
||||
name: "Nine Sols",
|
||||
controllerSupport: "full",
|
||||
releaseDate: new Date("2024-05-29T06:53:24.000Z"),
|
||||
compatibility: "high" as const,
|
||||
size: {
|
||||
downloadSize: 7907568608,// 7.91 GB
|
||||
sizeOnDisk: 13176088178,// 13.18 GB
|
||||
},
|
||||
primaryGenre: "Action",
|
||||
score: 4.7,
|
||||
description: "Nine Sols is a lore rich, hand-drawn 2D action-platformer featuring Sekiro-inspired deflection focused combat. Embark on a journey of eastern fantasy, explore the land once home to an ancient alien race, and follow a vengeful hero’s quest to slay the 9 Sols, formidable rulers of this forsaken realm.",
|
||||
}
|
||||
|
||||
export const Categories = {
|
||||
genres: [
|
||||
{
|
||||
name: "Action",
|
||||
slug: "action"
|
||||
},
|
||||
{
|
||||
name: "Adventure",
|
||||
slug: "adventure"
|
||||
},
|
||||
{
|
||||
name: "Indie",
|
||||
slug: "indie"
|
||||
}
|
||||
],
|
||||
tags: [
|
||||
{
|
||||
name: "Metroidvania",
|
||||
slug: "metroidvania",
|
||||
},
|
||||
{
|
||||
name: "Souls-like",
|
||||
slug: "souls-like",
|
||||
},
|
||||
{
|
||||
name: "Difficult",
|
||||
slug: "difficult",
|
||||
},
|
||||
],
|
||||
developers: [
|
||||
{
|
||||
name: "RedCandleGames",
|
||||
slug: "redcandlegames"
|
||||
}
|
||||
],
|
||||
publishers: [
|
||||
{
|
||||
name: "RedCandleGames",
|
||||
slug: "redcandlegames"
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
export const Game = {
|
||||
id: Id("game"),
|
||||
appID: 870780,
|
||||
name: "Control Ultimate Edition",
|
||||
slug: "control-ultimate-edition",
|
||||
tags: [GameTag], // Examples; Multiplayer, Family Sharing, Free To Play, Full Controller Support, In Game Purchases, Native Linux, Proton Compatibility Max (3), Proton Compatibility Mid (2), Proton Compatibility Low (1)
|
||||
genres: [GameGenre], // Examples; Action, Adventure,
|
||||
website: "https://controlgame.com",
|
||||
legalNotice: "Control © Remedy Entertainment Plc 2019. The Remedy, Northlight and Control logos are trademarks of Remedy Entertainment Plc. 505 Games and the 505 Games logo are trademarks of 505 Games SpA, and may be registered in the United States and other countries. All rights reserved.",
|
||||
releaseDate: new Date("27 Aug, 2020"),
|
||||
description: "Winner of over 80 awards, Control is a visually stunning third-person action-adventure that will keep you on the edge of your seat.",
|
||||
ratings: [GameRating],
|
||||
publishers: [{ ...DevelopmentTeam, type: "publisher" as const }],
|
||||
developers: [DevelopmentTeam],
|
||||
...BaseGame,
|
||||
...Categories
|
||||
}
|
||||
|
||||
export const image = {
|
||||
type: "screenshot" as const, // or square, vertical, horizontal, movie
|
||||
hash: "3a5e805fd4c1e04e26a97af0b9c6fab2dee91a19",
|
||||
gameID: Game.id,
|
||||
extractedColors: [{}]
|
||||
}
|
||||
|
||||
// export const Machine = {
|
||||
// id: Id("machine"),
|
||||
// userID: Id("user"),
|
||||
// country: "Kenya",
|
||||
// countryCode: "KE",
|
||||
// timezone: "Africa/Nairobi",
|
||||
// location: { latitude: 36.81550, longitude: -1.28410 },
|
||||
// fingerprint: "fc27f428f9ca47d4b41b707ae0c62090",
|
||||
// export const image = {
|
||||
// type: "screenshot" as const, // or square, vertical, horizontal, movie
|
||||
// hash: "3a5e805fd4c1e04e26a97af0b9c6fab2dee91a19",
|
||||
// gameID: Game.id,
|
||||
// extractedColors: [{}]
|
||||
// }
|
||||
|
||||
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { timestamps, } from "../drizzle/types";
|
||||
import { steamTable } from "../steam/steam.sql";
|
||||
import { pgTable,primaryKey, varchar } from "drizzle-orm/pg-core";
|
||||
import { index, pgTable, primaryKey, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const friendTable = pgTable(
|
||||
"friends_list",
|
||||
@@ -21,5 +21,6 @@ export const friendTable = pgTable(
|
||||
primaryKey({
|
||||
columns: [table.steamID, table.friendSteamID]
|
||||
}),
|
||||
index("idx_friends_list_friend_steam_id").on(table.friendSteamID),
|
||||
]
|
||||
);
|
||||
@@ -8,10 +8,10 @@ import { friendTable } from "./friend.sql";
|
||||
import { userTable } from "../user/user.sql";
|
||||
import { steamTable } from "../steam/steam.sql";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||
import { groupBy, map, pipe, values } from "remeda";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
import { ErrorCodes, VisibleError } from "../error";
|
||||
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Friend {
|
||||
export const Info = Steam.Info
|
||||
@@ -24,12 +24,14 @@ export namespace Friend {
|
||||
.openapi({
|
||||
ref: "Friend",
|
||||
description: "Represents a friend's information stored on Nestri",
|
||||
example: { ...Examples.SteamAccount, user: Examples.User },
|
||||
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(
|
||||
@@ -45,6 +47,21 @@ export namespace 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({
|
||||
@@ -76,35 +93,41 @@ export namespace Friend {
|
||||
)
|
||||
)
|
||||
|
||||
export const list = async () =>
|
||||
useTransaction(async (tx) => {
|
||||
const userSteamAccounts =
|
||||
await tx
|
||||
.select()
|
||||
.from(steamTable)
|
||||
.where(eq(steamTable.userID, Actor.userID()))
|
||||
.execute();
|
||||
|
||||
if (userSteamAccounts.length === 0) {
|
||||
return []; // User has no steam accounts
|
||||
}
|
||||
|
||||
const friendPromises =
|
||||
userSteamAccounts.map(async (steamAccount) => {
|
||||
return await fromSteamID(steamAccount.id)
|
||||
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))
|
||||
)
|
||||
|
||||
return (await Promise.all(friendPromises)).flat()
|
||||
})
|
||||
|
||||
export const fromSteamID = fn(
|
||||
InputInfo.shape.steamID,
|
||||
(steamID) =>
|
||||
export const fromFriendID = fn(
|
||||
InputInfo.shape.friendSteamID,
|
||||
(friendSteamID) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select({
|
||||
steam: steamTable,
|
||||
user: userTable
|
||||
user: userTable,
|
||||
})
|
||||
.from(friendTable)
|
||||
.innerJoin(
|
||||
@@ -117,28 +140,29 @@ export namespace Friend {
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(friendTable.steamID, steamID),
|
||||
eq(friendTable.steamID, Actor.steamID()),
|
||||
eq(friendTable.friendSteamID, friendSteamID),
|
||||
isNull(friendTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.orderBy(friendTable.timeCreated)
|
||||
.limit(100)
|
||||
.limit(1)
|
||||
.execute()
|
||||
.then((rows) => serialize(rows))
|
||||
.then(rows => serialize(rows).at(0))
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
export const areFriends = fn(
|
||||
InputInfo,
|
||||
(input) =>
|
||||
InputInfo.shape.friendSteamID,
|
||||
(friendSteamID) =>
|
||||
useTransaction(async (tx) => {
|
||||
const result = await tx
|
||||
.select()
|
||||
.from(friendTable)
|
||||
.where(
|
||||
and(
|
||||
eq(friendTable.steamID, input.steamID),
|
||||
eq(friendTable.friendSteamID, input.friendSteamID),
|
||||
eq(friendTable.steamID, Actor.steamID()),
|
||||
eq(friendTable.friendSteamID, friendSteamID),
|
||||
isNull(friendTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
@@ -154,7 +178,7 @@ export namespace Friend {
|
||||
): z.infer<typeof Info>[] {
|
||||
return pipe(
|
||||
input,
|
||||
groupBy((row) => row.steam.id.toString()),
|
||||
groupBy((row) => row.steam.id),
|
||||
values(),
|
||||
map((group) => ({
|
||||
...Steam.serialize(group[0].steam),
|
||||
|
||||
33
packages/core/src/game/game.sql.ts
Normal file
33
packages/core/src/game/game.sql.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { timestamps } from "../drizzle/types";
|
||||
import { baseGamesTable } from "../base-game/base-game.sql";
|
||||
import { categoriesTable } from "../categories/categories.sql";
|
||||
import { index, pgTable, primaryKey, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const gamesTable = pgTable(
|
||||
'games',
|
||||
{
|
||||
...timestamps,
|
||||
baseGameID: varchar('base_game_id', { length: 255 })
|
||||
.notNull()
|
||||
.references(() => baseGamesTable.id,
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
categorySlug: varchar('category_slug', { length: 255 })
|
||||
.notNull()
|
||||
.references(() => categoriesTable.slug,
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
categoryType: varchar('category_type', { length: 255 })
|
||||
.notNull()
|
||||
.references(() => categoriesTable.type,
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.baseGameID, table.categorySlug, table.categoryType]
|
||||
}),
|
||||
index("idx_games_category_slug").on(table.categorySlug),
|
||||
index("idx_games_category_type").on(table.categoryType),
|
||||
]
|
||||
);
|
||||
112
packages/core/src/game/index.ts
Normal file
112
packages/core/src/game/index.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Examples } from "../examples";
|
||||
import { BaseGame } from "../base-game";
|
||||
import { gamesTable } from "./game.sql";
|
||||
import { Categories } from "../categories";
|
||||
import { eq, and, isNull } from "drizzle-orm";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { groupBy, map, pipe, uniqueBy, values } from "remeda";
|
||||
import { baseGamesTable } from "../base-game/base-game.sql";
|
||||
import { categoriesTable } from "../categories/categories.sql";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Game {
|
||||
export const Info = z
|
||||
.intersection(BaseGame.Info, Categories.Info)
|
||||
.openapi({
|
||||
ref: "Game",
|
||||
description: "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 const InputInfo = createSelectSchema(gamesTable)
|
||||
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true })
|
||||
|
||||
export const create = fn(
|
||||
InputInfo,
|
||||
(input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const results =
|
||||
await tx
|
||||
.select()
|
||||
.from(gamesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(gamesTable.categorySlug, input.categorySlug),
|
||||
eq(gamesTable.categoryType, input.categoryType),
|
||||
eq(gamesTable.baseGameID, input.baseGameID),
|
||||
isNull(gamesTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.execute()
|
||||
|
||||
if (results.length > 0) return null
|
||||
|
||||
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,
|
||||
})
|
||||
.from(gamesTable)
|
||||
.innerJoin(baseGamesTable,
|
||||
eq(baseGamesTable.id, gamesTable.baseGameID)
|
||||
)
|
||||
.leftJoin(categoriesTable,
|
||||
and(
|
||||
eq(categoriesTable.slug, gamesTable.categorySlug),
|
||||
eq(categoriesTable.type, gamesTable.categoryType),
|
||||
)
|
||||
)
|
||||
.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 }[],
|
||||
): 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 byType = Categories.serialize(cats)
|
||||
return {
|
||||
...game,
|
||||
...byType,
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
101
packages/core/src/library/index.ts
Normal file
101
packages/core/src/library/index.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Game } from "../game";
|
||||
import { gamesTable } from "../game/game.sql";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { steamLibraryTable } from "./library.sql";
|
||||
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
|
||||
import { baseGamesTable } from "../base-game/base-game.sql";
|
||||
import { categoriesTable } from "../categories/categories.sql";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
import { Actor } from "../actor";
|
||||
|
||||
export namespace Library {
|
||||
export const Info = createSelectSchema(steamLibraryTable)
|
||||
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true })
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const add = fn(
|
||||
Info,
|
||||
async (input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const results =
|
||||
await tx
|
||||
.select()
|
||||
.from(steamLibraryTable)
|
||||
.where(
|
||||
and(
|
||||
eq(steamLibraryTable.gameID, input.gameID),
|
||||
eq(steamLibraryTable.ownerID, input.ownerID),
|
||||
isNull(steamLibraryTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.execute()
|
||||
|
||||
if (results.length > 0) return null
|
||||
|
||||
await tx
|
||||
.insert(steamLibraryTable)
|
||||
.values({
|
||||
ownerID: input.ownerID,
|
||||
gameID: input.gameID
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [steamLibraryTable.ownerID, steamLibraryTable.gameID],
|
||||
set: { timeDeleted: null }
|
||||
})
|
||||
|
||||
})
|
||||
)
|
||||
|
||||
export const remove = fn(
|
||||
Info,
|
||||
(input) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.update(steamLibraryTable)
|
||||
.set({ timeDeleted: sql`now()` })
|
||||
.where(
|
||||
and(
|
||||
eq(steamLibraryTable.ownerID, input.ownerID),
|
||||
eq(steamLibraryTable.gameID, input.gameID),
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
export const list = () =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select({
|
||||
games: baseGamesTable,
|
||||
categories: categoriesTable,
|
||||
})
|
||||
.from(steamLibraryTable)
|
||||
.where(
|
||||
and(
|
||||
eq(steamLibraryTable.ownerID, Actor.steamID()),
|
||||
isNull(steamLibraryTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.innerJoin(
|
||||
baseGamesTable,
|
||||
eq(baseGamesTable.id, steamLibraryTable.gameID),
|
||||
)
|
||||
.leftJoin(
|
||||
gamesTable,
|
||||
eq(gamesTable.baseGameID, baseGamesTable.id),
|
||||
)
|
||||
.leftJoin(
|
||||
categoriesTable,
|
||||
and(
|
||||
eq(categoriesTable.slug, gamesTable.categorySlug),
|
||||
eq(categoriesTable.type, gamesTable.categoryType),
|
||||
)
|
||||
)
|
||||
.execute()
|
||||
.then(rows => Game.serialize(rows))
|
||||
)
|
||||
|
||||
}
|
||||
28
packages/core/src/library/library.sql.ts
Normal file
28
packages/core/src/library/library.sql.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { timestamps, } from "../drizzle/types";
|
||||
import { steamTable } from "../steam/steam.sql";
|
||||
import { baseGamesTable } from "../base-game/base-game.sql";
|
||||
import { index, pgTable, primaryKey, varchar, } from "drizzle-orm/pg-core";
|
||||
|
||||
//TODO: Add playtime here
|
||||
export const steamLibraryTable = pgTable(
|
||||
"game_libraries",
|
||||
{
|
||||
...timestamps,
|
||||
gameID: varchar("game_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => baseGamesTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
ownerID: varchar("owner_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => steamTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.gameID, table.ownerID]
|
||||
}),
|
||||
index("idx_game_libraries_owner_id").on(table.ownerID),
|
||||
],
|
||||
);
|
||||
@@ -3,8 +3,9 @@ import { Actor } from "../actor";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import { createID, fn } from "../utils";
|
||||
import { and, eq, isNull } from "drizzle-orm"
|
||||
import { memberTable, RoleEnum } from "./member.sql";
|
||||
import { createTransaction } from "../drizzle/transaction";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Member {
|
||||
export const Info = z
|
||||
@@ -60,6 +61,46 @@ export namespace Member {
|
||||
}),
|
||||
);
|
||||
|
||||
export const fromTeamID = fn(
|
||||
Info.shape.teamID,
|
||||
(teamID) =>
|
||||
useTransaction((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(memberTable)
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.userID, Actor.userID()),
|
||||
eq(memberTable.teamID, teamID),
|
||||
isNull(memberTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.execute()
|
||||
.then(rows => rows.map(serialize).at(0))
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
export const fromUserID = fn(
|
||||
z.string(),
|
||||
(userID) =>
|
||||
useTransaction((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(memberTable)
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.userID, userID),
|
||||
eq(memberTable.teamID, Actor.teamID()),
|
||||
isNull(memberTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.execute()
|
||||
.then(rows => rows.map(serialize).at(0))
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
/**
|
||||
* Converts a raw member database row into a standardized {@link Member.Info} object.
|
||||
*
|
||||
|
||||
@@ -142,50 +142,26 @@ export namespace Steam {
|
||||
}),
|
||||
);
|
||||
|
||||
// TODO: This needs to be handled better, as it has the potential to turn unnecessary fields into `null`
|
||||
// export const update = fn(
|
||||
// Info
|
||||
// .extend({
|
||||
// useUser: z.boolean(),
|
||||
// })
|
||||
// .partial({
|
||||
// useUser: true,
|
||||
// userID: true,
|
||||
// status: true,
|
||||
// name: true,
|
||||
// lastSyncedAt: true,
|
||||
// avatarHash: true,
|
||||
// username: true,
|
||||
// realName: true,
|
||||
// limitations: true,
|
||||
// profileUrl: true,
|
||||
// steamMemberSince: true,
|
||||
// }),
|
||||
// async (input) =>
|
||||
// useTransaction(async (tx) => {
|
||||
// const userID = typeof input.userID === "string" ? input.userID : input.useUser ? Actor.userID() : undefined;
|
||||
// await tx
|
||||
// .update(steamTable)
|
||||
// .set({
|
||||
// userID,
|
||||
// id: input.id,
|
||||
// name: input.name,
|
||||
// realName: input.realName,
|
||||
// profileUrl: input.profileUrl,
|
||||
// avatarHash: input.avatarHash,
|
||||
// limitations: input.limitations,
|
||||
// status: input.status ?? "offline",
|
||||
// username: input.username ?? "unknown",
|
||||
// steamMemberSince: input.steamMemberSince,
|
||||
// lastSyncedAt: input.lastSyncedAt ?? Common.utc(),
|
||||
// })
|
||||
// .where(eq(steamTable.id, input.id));
|
||||
|
||||
// await afterTx(async () =>
|
||||
// bus.publish(Resource.Bus, Events.Updated, { userID: userID ?? null, steamID: input.id })
|
||||
// );
|
||||
// })
|
||||
// )
|
||||
export const updateOwner = fn(
|
||||
z
|
||||
.object({
|
||||
userID: z.string(),
|
||||
steamID: z.string()
|
||||
})
|
||||
.partial({
|
||||
userID: true
|
||||
}),
|
||||
(input) =>
|
||||
useTransaction(async (tx) => {
|
||||
const userID = input.userID ?? Actor.userID()
|
||||
await tx
|
||||
.update(steamTable)
|
||||
.set({
|
||||
userID
|
||||
})
|
||||
.where(eq(steamTable.id, input.steamID));
|
||||
})
|
||||
)
|
||||
|
||||
export const fromUserID = fn(
|
||||
z.string().min(1),
|
||||
|
||||
@@ -19,7 +19,7 @@ export const steamTable = pgTable(
|
||||
"steam_accounts",
|
||||
{
|
||||
...timestamps,
|
||||
id: varchar("steam_id", { length: 255 })
|
||||
id: varchar("id", { length: 255 })
|
||||
.primaryKey()
|
||||
.notNull(),
|
||||
userID: ulid("user_id")
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createID, fn, Invite } from "../utils";
|
||||
import { memberTable } from "../member/member.sql";
|
||||
import { groupBy, pipe, values, map } from "remeda";
|
||||
import { createTransaction, useTransaction, type Transaction } from "../drizzle/transaction";
|
||||
import { VisibleError } from "../error";
|
||||
|
||||
export namespace Team {
|
||||
export const Info = z
|
||||
@@ -110,6 +111,12 @@ export namespace Team {
|
||||
ownerID: input.ownerID ?? Actor.userID(),
|
||||
maxMembers: input.maxMembers ?? 1,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [teamTable.slug],
|
||||
set: {
|
||||
timeDeleted: null
|
||||
}
|
||||
})
|
||||
|
||||
return id;
|
||||
})
|
||||
|
||||
94
packages/functions/src/api/friend.ts
Normal file
94
packages/functions/src/api/friend.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { z } from "zod"
|
||||
import { Hono } from "hono";
|
||||
import { validator } from "hono-openapi/zod";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { Friend } from "@nestri/core/friend/index";
|
||||
import { ErrorResponses, notPublic, Result } from "./utils";
|
||||
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
|
||||
export namespace FriendApi {
|
||||
export const route = new Hono()
|
||||
.use(notPublic)
|
||||
.get("/",
|
||||
describeRoute({
|
||||
tags: ["Friend"],
|
||||
summary: "List friends accounts",
|
||||
description: "List all this user's friends accounts",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Friend.Info.array().openapi({
|
||||
description: "All friends accounts",
|
||||
example: [Examples.Friend]
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Friends accounts details"
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
404: ErrorResponses[404],
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
async (c) =>
|
||||
c.json({
|
||||
data: await Friend.list()
|
||||
})
|
||||
)
|
||||
.get("/:id",
|
||||
describeRoute({
|
||||
tags: ["Friend"],
|
||||
summary: "Get a friend",
|
||||
description: "Get a friend's details by their SteamID",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Friend.Info.openapi({
|
||||
description: "Friend's accounts",
|
||||
example: Examples.Friend
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Friends accounts details"
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
404: ErrorResponses[404],
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: z.string().openapi({
|
||||
description: "ID of the friend to get",
|
||||
example: Examples.Friend.id,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const friendSteamID = c.req.valid("param").id
|
||||
|
||||
const friend = await Friend.fromFriendID(friendSteamID)
|
||||
|
||||
if (!friend) {
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
`Friend ${friendSteamID} not found`
|
||||
)
|
||||
}
|
||||
|
||||
return c.json({
|
||||
data: friend
|
||||
})
|
||||
|
||||
}
|
||||
)
|
||||
}
|
||||
92
packages/functions/src/api/game.ts
Normal file
92
packages/functions/src/api/game.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { z } from "zod"
|
||||
import { Hono } from "hono";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { Game } from "@nestri/core/game/index";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { Library } from "@nestri/core/library/index";
|
||||
import { ErrorResponses, notPublic, Result, validator } from "./utils";
|
||||
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
|
||||
export namespace GameApi {
|
||||
export const route = new Hono()
|
||||
.use(notPublic)
|
||||
.get("/",
|
||||
describeRoute({
|
||||
tags: ["Game"],
|
||||
summary: "List games",
|
||||
description: "List all the games on a user's library",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Game.Info.array().openapi({
|
||||
description: "All games",
|
||||
example: [Examples.Game]
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Game details"
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
404: ErrorResponses[404],
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
async (c) =>
|
||||
c.json({
|
||||
data: await Library.list()
|
||||
})
|
||||
)
|
||||
.get("/:id",
|
||||
describeRoute({
|
||||
tags: ["Game"],
|
||||
summary: "Get game",
|
||||
description: "Get a game by its id, it does not have to be in user's library",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Game.Info.openapi({
|
||||
description: "Game details",
|
||||
example: Examples.Game
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Game details"
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: z.string().openapi({
|
||||
description: "ID of the game to get",
|
||||
example: Examples.Game.id,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const gameID = c.req.valid("param").id
|
||||
|
||||
const game = await Game.fromID(gameID)
|
||||
|
||||
if (!game) {
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
`Game ${gameID} does not exist`
|
||||
)
|
||||
}
|
||||
|
||||
return c.json({
|
||||
data: game
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import "zod-openapi/extend";
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { GameApi } from "./game";
|
||||
import { SteamApi } from "./steam";
|
||||
import { auth } from "./utils/auth";
|
||||
import { FriendApi } from "./friend";
|
||||
import { logger } from "hono/logger";
|
||||
import { Realtime } from "./realtime";
|
||||
import { auth } from "./utils/auth";
|
||||
import { AccountApi } from "./account";
|
||||
import { openAPISpecs } from "hono-openapi";
|
||||
import { patchLogger } from "../utils/patch-logger";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
import { SteamApi } from "./steam";
|
||||
|
||||
patchLogger();
|
||||
|
||||
@@ -25,8 +27,10 @@ app
|
||||
|
||||
const routes = app
|
||||
.get("/", (c) => c.text("Hello World!"))
|
||||
.route("/realtime", Realtime.route)
|
||||
.route("/games",GameApi.route)
|
||||
.route("/steam", SteamApi.route)
|
||||
.route("/realtime", Realtime.route)
|
||||
.route("/friends", FriendApi.route)
|
||||
.route("/account", AccountApi.route)
|
||||
.onError((error, c) => {
|
||||
if (error instanceof VisibleError) {
|
||||
|
||||
@@ -5,15 +5,44 @@ import { Actor } from "@nestri/core/actor";
|
||||
import SteamCommunity from "steamcommunity";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { Steam } from "@nestri/core/steam/index";
|
||||
import { ErrorResponses, validator } from "./utils";
|
||||
import { Team } from "@nestri/core/team/index";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { Member } from "@nestri/core/member/index";
|
||||
import { ErrorResponses, validator, Result } from "./utils";
|
||||
import { Credentials } from "@nestri/core/credentials/index";
|
||||
import { LoginSession, EAuthTokenPlatformType } from "steam-session";
|
||||
import { Team } from "@nestri/core/team/index";
|
||||
import { Member } from "@nestri/core/member/index";
|
||||
import type CSteamUser from "steamcommunity/classes/CSteamUser";
|
||||
|
||||
export namespace SteamApi {
|
||||
export const route = new Hono()
|
||||
.get("/",
|
||||
describeRoute({
|
||||
tags: ["Steam"],
|
||||
summary: "List Steam accounts",
|
||||
description: "List all Steam accounts belonging to this user",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Steam.Info.array().openapi({
|
||||
description: "All linked Steam accounts",
|
||||
example: [Examples.SteamAccount]
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Linked Steam accounts details"
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
async (c) =>
|
||||
c.json({
|
||||
data: await Steam.list()
|
||||
})
|
||||
)
|
||||
.get("/login",
|
||||
describeRoute({
|
||||
tags: ["Steam"],
|
||||
@@ -178,6 +207,8 @@ export namespace SteamApi {
|
||||
steamID
|
||||
})
|
||||
})
|
||||
} else {
|
||||
await Steam.updateOwner({ userID: currentUser.userID, steamID })
|
||||
}
|
||||
|
||||
await stream.writeSSE({
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Actor } from "@nestri/core/actor";
|
||||
import { type MiddlewareHandler } from "hono";
|
||||
import { createClient } from "@openauthjs/openauth/client";
|
||||
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
import { Member } from "@nestri/core/member/index";
|
||||
|
||||
const client = createClient({
|
||||
clientID: "api",
|
||||
@@ -43,23 +44,34 @@ export const auth: MiddlewareHandler = async (c, next) => {
|
||||
}
|
||||
|
||||
if (result.subject.type === "user") {
|
||||
const user = { ...result.subject.properties }
|
||||
const teamID = c.req.header("x-nestri-team");
|
||||
if (!teamID) {
|
||||
return Actor.provide("user", {
|
||||
...user
|
||||
}, next);
|
||||
return Actor.provide(result.subject.type, result.subject.properties, next);
|
||||
}
|
||||
const userID = result.subject.properties.userID
|
||||
return Actor.provide(
|
||||
"system",
|
||||
{
|
||||
teamID
|
||||
},
|
||||
async () =>
|
||||
Actor.provide("user", {
|
||||
...user
|
||||
}, next)
|
||||
);
|
||||
async () => {
|
||||
const member = await Member.fromUserID(userID)
|
||||
if (!member || !member.userID) {
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`You don't have permission to access this resource.`
|
||||
)
|
||||
}
|
||||
return Actor.provide(
|
||||
"member",
|
||||
{
|
||||
steamID: member.steamID,
|
||||
userID: member.userID,
|
||||
teamID: member.teamID
|
||||
},
|
||||
next)
|
||||
});
|
||||
}
|
||||
|
||||
return Actor.provide("public", {}, next);
|
||||
|
||||
@@ -451,7 +451,7 @@ export function CreateTeamComponent() {
|
||||
|
||||
// team slug
|
||||
stream.addEventListener("team_slug", async (e) => {
|
||||
await account.refresh(account.current.email)
|
||||
await account.refresh(account.current.id)
|
||||
{/**FIXME: Somehow this does not work when the user is in the "/new" page */ }
|
||||
nav(`/${JSON.parse(e.data).username}`)
|
||||
});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Size } from "@nestri/core/src/base-game/base-game.sql";
|
||||
import { type Limitations } from "@nestri/core/src/steam/steam.sql";
|
||||
import {
|
||||
json,
|
||||
table,
|
||||
number,
|
||||
string,
|
||||
ANYONE_CAN,
|
||||
enumeration,
|
||||
createSchema,
|
||||
relationships,
|
||||
definePermissions,
|
||||
@@ -30,11 +33,11 @@ const users = table("users")
|
||||
|
||||
const steam_accounts = table("steam_accounts")
|
||||
.columns({
|
||||
id: string(),
|
||||
name: string(),
|
||||
status: string(),
|
||||
user_id: string(),
|
||||
username: string(),
|
||||
steam_id: string(),
|
||||
avatar_hash: string(),
|
||||
member_since: number(),
|
||||
last_synced_at: number(),
|
||||
@@ -43,7 +46,7 @@ const steam_accounts = table("steam_accounts")
|
||||
limitations: json<Limitations>(),
|
||||
...timestamps,
|
||||
})
|
||||
.primaryKey("steam_id");
|
||||
.primaryKey("id");
|
||||
|
||||
const teams = table("teams")
|
||||
.columns({
|
||||
@@ -75,9 +78,48 @@ const friends_list = table("friends_list")
|
||||
})
|
||||
.primaryKey("steam_id", "friend_steam_id");
|
||||
|
||||
const games = table("games")
|
||||
.columns({
|
||||
base_game_id: string(),
|
||||
category_slug: string(),
|
||||
...timestamps
|
||||
})
|
||||
.primaryKey("category_slug", "base_game_id")
|
||||
|
||||
const base_games = table("base_games")
|
||||
.columns({
|
||||
id: string(),
|
||||
slug: string(),
|
||||
name: string(),
|
||||
release_date: number(),
|
||||
size: json<Size>(),
|
||||
description: string(),
|
||||
primary_genre: string(),
|
||||
controller_support: string().optional(),
|
||||
compatibility: enumeration<"high" | "mid" | "low">(),
|
||||
score: number(),
|
||||
...timestamps
|
||||
})
|
||||
.primaryKey("id")
|
||||
|
||||
const categories = table("categories")
|
||||
.columns({
|
||||
slug: string(),
|
||||
type: enumeration<"tag" | "genre" | "publisher" | "developer">(),
|
||||
name: string(),
|
||||
...timestamps
|
||||
})
|
||||
.primaryKey("slug")
|
||||
|
||||
const game_libraries = table("game_libraries")
|
||||
.columns({
|
||||
game_id: string(),
|
||||
owner_id: string()
|
||||
})
|
||||
|
||||
// Schema and Relationships
|
||||
export const schema = createSchema({
|
||||
tables: [users, steam_accounts, teams, members, friends_list],
|
||||
tables: [users, steam_accounts, teams, members, friends_list, categories, base_games, games, game_libraries],
|
||||
relationships: [
|
||||
relationships(steam_accounts, (r) => ({
|
||||
user: r.one({
|
||||
@@ -86,20 +128,25 @@ export const schema = createSchema({
|
||||
destField: ["id"],
|
||||
}),
|
||||
memberEntries: r.many({
|
||||
sourceField: ["steam_id"],
|
||||
sourceField: ["id"],
|
||||
destSchema: members,
|
||||
destField: ["steam_id"],
|
||||
}),
|
||||
friends: r.many({
|
||||
sourceField: ["steam_id"],
|
||||
sourceField: ["id"],
|
||||
destSchema: friends_list,
|
||||
destField: ["steam_id"],
|
||||
}),
|
||||
friendOf: r.many({
|
||||
sourceField: ["steam_id"],
|
||||
sourceField: ["id"],
|
||||
destSchema: friends_list,
|
||||
destField: ["friend_steam_id"],
|
||||
}),
|
||||
libraries: r.many({
|
||||
sourceField: ["id"],
|
||||
destSchema: game_libraries,
|
||||
destField: ["owner_id"]
|
||||
})
|
||||
})),
|
||||
relationships(users, (r) => ({
|
||||
teams: r.many({
|
||||
@@ -142,23 +189,67 @@ export const schema = createSchema({
|
||||
destField: ["id"],
|
||||
}),
|
||||
steamAccount: r.one({
|
||||
sourceField: ["steam_id"],
|
||||
sourceField: ["id"],
|
||||
destSchema: steam_accounts,
|
||||
destField: ["steam_id"],
|
||||
destField: ["id"],
|
||||
}),
|
||||
})),
|
||||
relationships(friends_list, (r) => ({
|
||||
steam: r.one({
|
||||
sourceField: ["steam_id"],
|
||||
destSchema: steam_accounts,
|
||||
destField: ["steam_id"],
|
||||
destField: ["id"],
|
||||
}),
|
||||
friend: r.one({
|
||||
sourceField: ["friend_steam_id"],
|
||||
destSchema: steam_accounts,
|
||||
destField: ["steam_id"],
|
||||
destField: ["id"],
|
||||
}),
|
||||
})),
|
||||
relationships(base_games, (r) => ({
|
||||
games: r.many({
|
||||
sourceField: ["id"],
|
||||
destSchema: games,
|
||||
destField: ["base_game_id"]
|
||||
}),
|
||||
libraries: r.many({
|
||||
sourceField: ["id"],
|
||||
destSchema: game_libraries,
|
||||
destField: ["game_id"]
|
||||
})
|
||||
})),
|
||||
relationships(categories, (r) => ({
|
||||
games: r.many({
|
||||
sourceField: ["slug"],
|
||||
destSchema: games,
|
||||
destField: ["category_slug"]
|
||||
})
|
||||
})),
|
||||
relationships(games, (r) => ({
|
||||
category: r.one({
|
||||
sourceField: ["category_slug"],
|
||||
destSchema: categories,
|
||||
destField: ["slug"],
|
||||
}),
|
||||
base_game: r.one({
|
||||
sourceField: ["base_game_id"],
|
||||
destSchema: base_games,
|
||||
destField: ["id"],
|
||||
}),
|
||||
})),
|
||||
relationships(game_libraries, (r) => ({
|
||||
base_game: r.one({
|
||||
sourceField: ["game_id"],
|
||||
destSchema: base_games,
|
||||
destField: ["id"],
|
||||
}),
|
||||
owner: r.one({
|
||||
sourceField: ["owner_id"],
|
||||
destSchema: steam_accounts,
|
||||
destField: ["id"],
|
||||
}),
|
||||
})),
|
||||
|
||||
],
|
||||
});
|
||||
|
||||
@@ -197,7 +288,7 @@ export const permissions = definePermissions<Auth, Schema>(schema, () => {
|
||||
//Allow friends to view friends steam accounts
|
||||
(auth: Auth, q: ExpressionBuilder<Schema, 'steam_accounts'>) => q.exists("friends", (u) => u.related("friend", (f) => f.where("user_id", auth.sub))),
|
||||
//allow other team members to see a user's steam account
|
||||
(auth: Auth, q: ExpressionBuilder<Schema, 'steam_accounts'>) => q.exists("memberEntries", (u) => u.related("team",(t) => t.related("members", (m) => m.where("user_id", auth.sub)))),
|
||||
(auth: Auth, q: ExpressionBuilder<Schema, 'steam_accounts'>) => q.exists("memberEntries", (u) => u.related("team", (t) => t.related("members", (m) => m.where("user_id", auth.sub)))),
|
||||
]
|
||||
},
|
||||
},
|
||||
@@ -216,5 +307,30 @@ export const permissions = definePermissions<Auth, Schema>(schema, () => {
|
||||
]
|
||||
},
|
||||
},
|
||||
//Games are publicly viewable
|
||||
games: {
|
||||
row: {
|
||||
select: ANYONE_CAN
|
||||
}
|
||||
},
|
||||
base_games: {
|
||||
row: {
|
||||
select: ANYONE_CAN
|
||||
}
|
||||
},
|
||||
categories: {
|
||||
row: {
|
||||
select: ANYONE_CAN
|
||||
}
|
||||
},
|
||||
game_libraries: {
|
||||
row: {
|
||||
select: [
|
||||
(auth: Auth, q: ExpressionBuilder<Schema, 'game_libraries'>) => q.exists("owner", (u) => u.where("user_id", auth.sub)),
|
||||
(auth: Auth, q: ExpressionBuilder<Schema, 'game_libraries'>) => q.exists("owner", (u) => u.related("memberEntries", (f) => f.where("user_id", auth.sub))),
|
||||
(auth: Auth, q: ExpressionBuilder<Schema, 'game_libraries'>) => q.exists("owner", (u) => u.related("friends", (f) => f.related("friend", (s) => s.where("user_id", auth.sub)))),
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user