mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 16:55:37 +02:00
Compare commits
32 Commits
feat/prici
...
feat/image
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fd5608e6e | ||
|
|
a47dc91b22 | ||
|
|
0124af1b70 | ||
|
|
e67a8d2b32 | ||
|
|
8f4bb05143 | ||
|
|
84357ac5bf | ||
|
|
e11012e8d9 | ||
|
|
c0194ecef4 | ||
|
|
ae364f69bd | ||
|
|
d7e6da12ac | ||
|
|
6e19b2e9a0 | ||
|
|
dd20c0049d | ||
|
|
14e4176344 | ||
|
|
baf178afc5 | ||
|
|
80deb82d25 | ||
|
|
e1a903a7c9 | ||
|
|
cc2065299d | ||
|
|
0cc9effdec | ||
|
|
82dfd6506d | ||
|
|
6051e11921 | ||
|
|
86670d5931 | ||
|
|
35f009e925 | ||
|
|
5806dc6e86 | ||
|
|
38ad74d14a | ||
|
|
0b995fa540 | ||
|
|
d933c1e61d | ||
|
|
b86fc625ba | ||
|
|
c250fd557c | ||
|
|
1923cdf2a3 | ||
|
|
7e69af977b | ||
|
|
70d629227a | ||
|
|
a0dc353561 |
@@ -132,9 +132,11 @@ RUN sed -i \
|
||||
# Core system components
|
||||
RUN --mount=type=cache,target=/var/cache/pacman/pkg \
|
||||
pacman -Sy --needed --noconfirm \
|
||||
vulkan-intel lib32-vulkan-intel vpl-gpu-rt mesa \
|
||||
vulkan-intel lib32-vulkan-intel vpl-gpu-rt \
|
||||
vulkan-radeon lib32-vulkan-radeon \
|
||||
mesa \
|
||||
steam steam-native-runtime gtk3 lib32-gtk3 \
|
||||
sudo xorg-xwayland seatd libinput labwc wlr-randr gamescope mangohud \
|
||||
sudo xorg-xwayland seatd libinput gamescope mangohud \
|
||||
libssh2 curl wget \
|
||||
pipewire pipewire-pulse pipewire-alsa wireplumber \
|
||||
noto-fonts-cjk supervisor jq chwd lshw pacman-contrib && \
|
||||
@@ -166,8 +168,7 @@ ENV USER="nestri" \
|
||||
USER_PWD="nestri1234" \
|
||||
XDG_RUNTIME_DIR=/run/user/1000 \
|
||||
HOME=/home/nestri \
|
||||
NVIDIA_DRIVER_CAPABILITIES=all \
|
||||
NVIDIA_VISIBLE_DEVICES=all
|
||||
NVIDIA_DRIVER_CAPABILITIES=all
|
||||
|
||||
RUN mkdir -p /home/${USER} && \
|
||||
groupadd -g ${GID} ${USER} && \
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
#FIXME: A simple docker-compose file for running the MoQ relay and the cachyos server
|
||||
49
infra/api.ts
49
infra/api.ts
@@ -5,15 +5,15 @@ import { secret } from "./secret";
|
||||
import { cluster } from "./cluster";
|
||||
import { postgres } from "./postgres";
|
||||
|
||||
export const api = new sst.aws.Service("Api", {
|
||||
export const apiService = new sst.aws.Service("Api", {
|
||||
cluster,
|
||||
cpu: $app.stage === "production" ? "2 vCPU" : undefined,
|
||||
memory: $app.stage === "production" ? "4 GB" : undefined,
|
||||
command: ["bun", "run", "./src/api/index.ts"],
|
||||
link: [
|
||||
bus,
|
||||
auth,
|
||||
postgres,
|
||||
secret.SteamApiKey,
|
||||
secret.PolarSecret,
|
||||
secret.PolarWebhookSecret,
|
||||
secret.NestriFamilyMonthly,
|
||||
@@ -22,12 +22,10 @@ export const api = new sst.aws.Service("Api", {
|
||||
secret.NestriProMonthly,
|
||||
secret.NestriProYearly,
|
||||
],
|
||||
command: ["bun", "run", "./src/api/index.ts"],
|
||||
image: {
|
||||
dockerfile: "packages/functions/Containerfile",
|
||||
},
|
||||
environment: {
|
||||
NO_COLOR: "1",
|
||||
},
|
||||
loadBalancer: {
|
||||
rules: [
|
||||
{
|
||||
@@ -37,9 +35,9 @@ export const api = new sst.aws.Service("Api", {
|
||||
],
|
||||
},
|
||||
dev: {
|
||||
url: "http://localhost:3001",
|
||||
command: "bun dev:api",
|
||||
directory: "packages/functions",
|
||||
url: "http://localhost:3001",
|
||||
},
|
||||
scaling:
|
||||
$app.stage === "production"
|
||||
@@ -48,16 +46,49 @@ export const api = new sst.aws.Service("Api", {
|
||||
max: 10,
|
||||
}
|
||||
: undefined,
|
||||
// For persisting actor state
|
||||
transform: {
|
||||
taskDefinition: (args) => {
|
||||
const volumes = $output(args.volumes).apply(v => {
|
||||
const next = [...(v || []), {
|
||||
name: "shared-tmp",
|
||||
dockerVolumeConfiguration: {
|
||||
scope: "shared",
|
||||
driver: "local"
|
||||
}
|
||||
}];
|
||||
|
||||
return next;
|
||||
})
|
||||
|
||||
// "containerDefinitions" is a JSON string, parse first
|
||||
let containers = $jsonParse(args.containerDefinitions);
|
||||
|
||||
containers = containers.apply((containerDefinitions) => {
|
||||
containerDefinitions[0].mountPoints = [
|
||||
...(containerDefinitions[0].mountPoints ?? []),
|
||||
{
|
||||
sourceVolume: "shared-tmp",
|
||||
containerPath: "/tmp"
|
||||
},
|
||||
]
|
||||
return containerDefinitions;
|
||||
});
|
||||
|
||||
args.volumes = volumes
|
||||
args.containerDefinitions = $jsonStringify(containers);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
export const apiRoute = new sst.aws.Router("ApiRoute", {
|
||||
export const api = !$dev ? new sst.aws.Router("ApiRoute", {
|
||||
routes: {
|
||||
// I think api.url should work all the same
|
||||
"/*": api.nodes.loadBalancer.dnsName,
|
||||
"/*": apiService.nodes.loadBalancer.dnsName,
|
||||
},
|
||||
domain: {
|
||||
name: "api." + domain,
|
||||
dns: sst.cloudflare.dns(),
|
||||
},
|
||||
})
|
||||
}) : apiService
|
||||
@@ -4,12 +4,11 @@ import { secret } from "./secret";
|
||||
import { cluster } from "./cluster";
|
||||
import { postgres } from "./postgres";
|
||||
|
||||
//FIXME: Use a shared /tmp folder
|
||||
export const auth = new sst.aws.Service("Auth", {
|
||||
export const authService = new sst.aws.Service("Auth", {
|
||||
cluster,
|
||||
cpu: $app.stage === "production" ? "1 vCPU" : undefined,
|
||||
memory: $app.stage === "production" ? "2 GB" : undefined,
|
||||
command: ["bun", "run", "./src/auth.ts"],
|
||||
command: ["bun", "run", "./src/auth/index.ts"],
|
||||
link: [
|
||||
bus,
|
||||
postgres,
|
||||
@@ -24,7 +23,7 @@ export const auth = new sst.aws.Service("Auth", {
|
||||
},
|
||||
environment: {
|
||||
NO_COLOR: "1",
|
||||
STORAGE: $dev ? "/tmp/persist.json" : "/mnt/efs/persist.json"
|
||||
STORAGE: "/tmp/persist.json"
|
||||
},
|
||||
loadBalancer: {
|
||||
rules: [
|
||||
@@ -52,15 +51,48 @@ export const auth = new sst.aws.Service("Auth", {
|
||||
max: 10,
|
||||
}
|
||||
: undefined,
|
||||
//For temporarily persisting the persist.json
|
||||
transform: {
|
||||
taskDefinition: (args) => {
|
||||
const volumes = $output(args.volumes).apply(v => {
|
||||
const next = [...(v || []), {
|
||||
name: "shared-tmp",
|
||||
dockerVolumeConfiguration: {
|
||||
scope: "shared",
|
||||
driver: "local"
|
||||
}
|
||||
}];
|
||||
|
||||
return next;
|
||||
})
|
||||
|
||||
// "containerDefinitions" is a JSON string, parse first
|
||||
let containers = $jsonParse(args.containerDefinitions);
|
||||
|
||||
containers = containers.apply((containerDefinitions) => {
|
||||
containerDefinitions[0].mountPoints = [
|
||||
...(containerDefinitions[0].mountPoints ?? []),
|
||||
{
|
||||
sourceVolume: "shared-tmp",
|
||||
containerPath: "/tmp"
|
||||
}
|
||||
]
|
||||
return containerDefinitions;
|
||||
});
|
||||
|
||||
args.volumes = volumes
|
||||
args.containerDefinitions = $jsonStringify(containers);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const authRoute = new sst.aws.Router("AuthRoute", {
|
||||
export const auth = !$dev ? new sst.aws.Router("AuthRoute", {
|
||||
routes: {
|
||||
// I think auth.url should work all the same
|
||||
"/*": auth.nodes.loadBalancer.dnsName,
|
||||
"/*": authService.nodes.loadBalancer.dnsName,
|
||||
},
|
||||
domain: {
|
||||
name: "auth." + domain,
|
||||
dns: sst.cloudflare.dns(),
|
||||
},
|
||||
})
|
||||
}) : authService
|
||||
63
infra/bus.ts
63
infra/bus.ts
@@ -1,23 +1,70 @@
|
||||
import { vpc } from "./vpc";
|
||||
// import { email } from "./email";
|
||||
import { allSecrets } from "./secret";
|
||||
import { secret } from "./secret";
|
||||
import { storage } from "./storage";
|
||||
import { postgres } from "./postgres";
|
||||
|
||||
export const dlq = new sst.aws.Queue("Dlq");
|
||||
|
||||
export const retryQueue = new sst.aws.Queue("RetryQueue");
|
||||
|
||||
export const bus = new sst.aws.Bus("Bus");
|
||||
|
||||
bus.subscribe("Event", {
|
||||
export const eventSub = bus.subscribe("Event", {
|
||||
vpc,
|
||||
handler: "./packages/functions/src/event/event.handler",
|
||||
handler: "packages/functions/src/events/index.handler",
|
||||
link: [
|
||||
// email,
|
||||
bus,
|
||||
storage,
|
||||
postgres,
|
||||
...allSecrets
|
||||
retryQueue,
|
||||
secret.PolarSecret,
|
||||
secret.SteamApiKey
|
||||
],
|
||||
environment: {
|
||||
RETRIES: "2",
|
||||
},
|
||||
memory: "3002 MB",// For faster processing of large(r) images
|
||||
timeout: "10 minutes",
|
||||
});
|
||||
|
||||
new aws.lambda.FunctionEventInvokeConfig("EventConfig", {
|
||||
functionName: $resolve([eventSub.nodes.function.name]).apply(
|
||||
([name]) => name,
|
||||
),
|
||||
maximumRetryAttempts: 1,
|
||||
destinationConfig: {
|
||||
onFailure: {
|
||||
destination: retryQueue.arn,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
retryQueue.subscribe({
|
||||
vpc,
|
||||
handler: "packages/functions/src/queues/retry.handler",
|
||||
timeout: "30 seconds",
|
||||
environment: {
|
||||
RETRIER_QUEUE_URL: retryQueue.url,
|
||||
},
|
||||
link: [
|
||||
dlq,
|
||||
retryQueue,
|
||||
eventSub.nodes.function,
|
||||
],
|
||||
timeout: "5 minutes",
|
||||
permissions: [
|
||||
{
|
||||
actions: ["ses:SendEmail"],
|
||||
resources: ["*"],
|
||||
actions: ["lambda:GetFunction", "lambda:InvokeFunction"],
|
||||
resources: [
|
||||
$interpolate`arn:aws:lambda:${aws.getRegionOutput().name}:${aws.getCallerIdentityOutput().accountId}:function:*`,
|
||||
],
|
||||
},
|
||||
],
|
||||
transform: {
|
||||
function: {
|
||||
deadLetterConfig: {
|
||||
targetArn: dlq.arn,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
57
infra/images.ts
Normal file
57
infra/images.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { domain } from "./dns";
|
||||
import { storage } from "./storage";
|
||||
|
||||
sst.Linkable.wrap(aws.iam.AccessKey, (resource) => ({
|
||||
properties: {
|
||||
key: resource.id,
|
||||
secret: resource.secret,
|
||||
},
|
||||
}))
|
||||
|
||||
const cache = new sst.cloudflare.Kv("ImageCache");
|
||||
|
||||
const bucket = new sst.cloudflare.Bucket("ImageBucket");
|
||||
|
||||
const lambdaInvokerUser = new aws.iam.User("ImageIAMUser", {
|
||||
name: `${$app.name}-${$app.stage}-ImageIAMUser`,
|
||||
forceDestroy: true
|
||||
});
|
||||
|
||||
const imageProcessorFunction = new sst.aws.Function("ImageProcessor",
|
||||
{
|
||||
memory: "1024 MB",
|
||||
link: [storage],
|
||||
timeout: "30 seconds",
|
||||
nodejs: { install: ["sharp"] },
|
||||
handler: "packages/functions/src/images/processor.handler",
|
||||
},
|
||||
);
|
||||
|
||||
new aws.iam.UserPolicy("InvokeLambdaPolicy", {
|
||||
user: lambdaInvokerUser.name,
|
||||
policy: $output({
|
||||
Version: "2012-10-17",
|
||||
Statement: [
|
||||
{
|
||||
Effect: "Allow",
|
||||
Action: ["lambda:InvokeFunction"],
|
||||
Resource: imageProcessorFunction.arn,
|
||||
},
|
||||
],
|
||||
}).apply(JSON.stringify),
|
||||
});
|
||||
|
||||
const accessKey = new aws.iam.AccessKey("ImageInvokerAccessKey", {
|
||||
user: lambdaInvokerUser.name,
|
||||
});
|
||||
|
||||
export const imageCdn = new sst.cloudflare.Worker("ImageCDN", {
|
||||
url: true,
|
||||
domain: "cdn." + domain,
|
||||
link: [bucket, cache, imageProcessorFunction, accessKey],
|
||||
handler: "packages/functions/src/images/index.ts",
|
||||
});
|
||||
|
||||
export const outputs = {
|
||||
cdn: imageCdn.url
|
||||
}
|
||||
@@ -1,13 +1,9 @@
|
||||
import { vpc } from "./vpc";
|
||||
import { isPermanentStage } from "./stage";
|
||||
|
||||
// TODO: Add a dev db to use, this will help with running zero locally... and testing it
|
||||
export const postgres = new sst.aws.Aurora("Database", {
|
||||
vpc,
|
||||
engine: "postgres",
|
||||
scaling: isPermanentStage
|
||||
? undefined
|
||||
: {
|
||||
scaling: {
|
||||
min: "0 ACU",
|
||||
max: "1 ACU",
|
||||
},
|
||||
@@ -49,20 +45,20 @@ new sst.x.DevCommand("Studio", {
|
||||
},
|
||||
});
|
||||
|
||||
const migrator = new sst.aws.Function("DatabaseMigrator", {
|
||||
handler: "packages/functions/src/migrator.handler",
|
||||
link: [postgres],
|
||||
copyFiles: [
|
||||
{
|
||||
from: "packages/core/migrations",
|
||||
to: "./migrations",
|
||||
},
|
||||
],
|
||||
});
|
||||
// const migrator = new sst.aws.Function("DatabaseMigrator", {
|
||||
// handler: "packages/functions/src/migrator.handler",
|
||||
// link: [postgres],
|
||||
// copyFiles: [
|
||||
// {
|
||||
// from: "packages/core/migrations",
|
||||
// to: "./migrations",
|
||||
// },
|
||||
// ],
|
||||
// });
|
||||
|
||||
if (!$dev) {
|
||||
new aws.lambda.Invocation("DatabaseMigratorInvocation", {
|
||||
input: Date.now().toString(),
|
||||
functionName: migrator.name,
|
||||
});
|
||||
}
|
||||
// if (!$dev) {
|
||||
// new aws.lambda.Invocation("DatabaseMigratorInvocation", {
|
||||
// input: Date.now().toString(),
|
||||
// functionName: migrator.name,
|
||||
// });
|
||||
// }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const secret = {
|
||||
PolarSecret: new sst.Secret("PolarSecret", process.env.POLAR_API_KEY),
|
||||
SteamApiKey: new sst.Secret("SteamApiKey"),
|
||||
GithubClientID: new sst.Secret("GithubClientID"),
|
||||
DiscordClientID: new sst.Secret("DiscordClientID"),
|
||||
PolarWebhookSecret: new sst.Secret("PolarWebhookSecret"),
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
new sst.x.DevCommand("Steam", {
|
||||
dev: {
|
||||
command: "bun dev",
|
||||
directory: "packages/steam",
|
||||
autostart: true,
|
||||
},
|
||||
});
|
||||
@@ -26,13 +26,15 @@ const zeroEnv = {
|
||||
ZERO_CHANGE_DB: connectionString,
|
||||
ZERO_REPLICA_FILE: "/tmp/nestri.db",
|
||||
ZERO_LITESTREAM_RESTORE_PARALLELISM: "64",
|
||||
ZERO_SHARD_ID: $app.stage,
|
||||
ZERO_APP_ID: $app.stage,
|
||||
ZERO_AUTH_JWKS_URL: $interpolate`${auth.url}/.well-known/jwks.json`,
|
||||
ZERO_INITIAL_SYNC_ROW_BATCH_SIZE: "30000",
|
||||
NODE_OPTIONS: "--max-old-space-size=8192",
|
||||
...($dev
|
||||
? {
|
||||
}
|
||||
: {
|
||||
ZERO_LITESTREAM_BACKUP_URL: $interpolate`s3://${storage.name}/zero`,
|
||||
ZERO_LITESTREAM_BACKUP_URL: $interpolate`s3://${storage.name}/zero/0`,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -84,44 +86,44 @@ const replicationManager = !$dev
|
||||
}) : undefined;
|
||||
|
||||
// Permissions deployment
|
||||
const permissions = new sst.aws.Function(
|
||||
"ZeroPermissions",
|
||||
{
|
||||
vpc,
|
||||
link: [postgres],
|
||||
handler: "packages/functions/src/zero.handler",
|
||||
// environment: { ["ZERO_UPSTREAM_DB"]: connectionString },
|
||||
copyFiles: [{
|
||||
from: "packages/zero/.permissions.sql",
|
||||
to: "./.permissions.sql"
|
||||
}],
|
||||
}
|
||||
);
|
||||
// const permissions = new sst.aws.Function(
|
||||
// "ZeroPermissions",
|
||||
// {
|
||||
// vpc,
|
||||
// link: [postgres],
|
||||
// handler: "packages/functions/src/zero.handler",
|
||||
// // environment: { ["ZERO_UPSTREAM_DB"]: connectionString },
|
||||
// copyFiles: [{
|
||||
// from: "packages/zero/permissions.sql",
|
||||
// to: "./.permissions.sql"
|
||||
// }],
|
||||
// }
|
||||
// );
|
||||
|
||||
if (replicationManager) {
|
||||
new aws.lambda.Invocation(
|
||||
"ZeroPermissionsInvocation",
|
||||
{
|
||||
input: Date.now().toString(),
|
||||
functionName: permissions.name,
|
||||
},
|
||||
{ dependsOn: replicationManager }
|
||||
);
|
||||
// new command.local.Command(
|
||||
// "ZeroPermission",
|
||||
// {
|
||||
// dir: process.cwd() + "/packages/zero",
|
||||
// environment: {
|
||||
// ZERO_UPSTREAM_DB: connectionString,
|
||||
// },
|
||||
// create: "bun run zero-deploy-permissions",
|
||||
// triggers: [Date.now()],
|
||||
// },
|
||||
// {
|
||||
// dependsOn: [replicationManager],
|
||||
// },
|
||||
// );
|
||||
}
|
||||
// if (replicationManager) {
|
||||
// new aws.lambda.Invocation(
|
||||
// "ZeroPermissionsInvocation",
|
||||
// {
|
||||
// input: Date.now().toString(),
|
||||
// functionName: permissions.name,
|
||||
// },
|
||||
// { dependsOn: replicationManager }
|
||||
// );
|
||||
// // new command.local.Command(
|
||||
// // "ZeroPermission",
|
||||
// // {
|
||||
// // dir: process.cwd() + "/packages/zero",
|
||||
// // environment: {
|
||||
// // ZERO_UPSTREAM_DB: connectionString,
|
||||
// // },
|
||||
// // create: "bun run zero-deploy-permissions",
|
||||
// // triggers: [Date.now()],
|
||||
// // },
|
||||
// // {
|
||||
// // dependsOn: [replicationManager],
|
||||
// // },
|
||||
// // );
|
||||
// }
|
||||
|
||||
export const zero = new sst.aws.Service("Zero", {
|
||||
cluster,
|
||||
@@ -144,14 +146,13 @@ export const zero = new sst.aws.Service("Zero", {
|
||||
ZERO_NUM_SYNC_WORKERS: "1",
|
||||
}
|
||||
: {
|
||||
ZERO_CHANGE_STREAMER_URI: replicationManager.url.apply((val) =>
|
||||
ZERO_CHANGE_STREAMER_URI: replicationManager?.url.apply((val) =>
|
||||
val.replace("http://", "ws://"),
|
||||
),
|
||||
) ?? "",
|
||||
ZERO_UPSTREAM_MAX_CONNS: "15",
|
||||
ZERO_CVR_MAX_CONNS: "160",
|
||||
}),
|
||||
},
|
||||
wait: true,
|
||||
health: {
|
||||
retries: 3,
|
||||
command: ["CMD-SHELL", "curl -f http://localhost:4848/ || exit 1"],
|
||||
|
||||
29
nestri.sln
29
nestri.sln
@@ -1,29 +0,0 @@
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.5.2.0
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "packages", "packages", "{809F86A1-1C4C-B159-0CD4-DF9D33D876CE}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "steam", "packages\steam\steam.csproj", "{96118F95-BF02-0ED3-9042-36FA1B740D67}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{96118F95-BF02-0ED3-9042-36FA1B740D67}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{96118F95-BF02-0ED3-9042-36FA1B740D67}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{96118F95-BF02-0ED3-9042-36FA1B740D67}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{96118F95-BF02-0ED3-9042-36FA1B740D67}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{96118F95-BF02-0ED3-9042-36FA1B740D67} = {809F86A1-1C4C-B159-0CD4-DF9D33D876CE}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {526AD703-4D15-43CF-B7C0-83F10D3158DB}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -3,6 +3,7 @@
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "4.20240821.1",
|
||||
"@pulumi/pulumi": "^3.134.0",
|
||||
"@tsconfig/node22": "^22.0.1",
|
||||
"@types/aws-lambda": "8.10.147",
|
||||
"prettier": "^3.2.5",
|
||||
"typescript": "^5.4.5"
|
||||
@@ -18,17 +19,17 @@
|
||||
},
|
||||
"overrides": {
|
||||
"@openauthjs/openauth": "0.4.3",
|
||||
"@rocicorp/zero": "0.16.2025022000"
|
||||
"steam-session": "1.9.3"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@macaron-css/solid@1.5.3": "patches/@macaron-css%2Fsolid@1.5.3.patch",
|
||||
"drizzle-orm@0.36.1": "patches/drizzle-orm@0.36.1.patch"
|
||||
"drizzle-orm@0.36.1": "patches/drizzle-orm@0.36.1.patch",
|
||||
"steam-session@1.9.3": "patches/steam-session@1.9.3.patch"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"core-js-pure",
|
||||
"esbuild",
|
||||
"protobufjs",
|
||||
"@rocicorp/zero-sqlite3",
|
||||
"workerd"
|
||||
],
|
||||
"workspaces": [
|
||||
@@ -36,6 +37,7 @@
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"sharp": "^0.34.2",
|
||||
"sst": "^3.11.21"
|
||||
}
|
||||
}
|
||||
94
packages/core/migrations/0010_certain_dust.sql
Normal file
94
packages/core/migrations/0010_certain_dust.sql
Normal file
@@ -0,0 +1,94 @@
|
||||
CREATE TYPE "public"."member_role" AS ENUM('child', 'adult');--> statement-breakpoint
|
||||
CREATE TYPE "public"."steam_status" AS ENUM('online', 'offline', 'dnd', 'playing');--> statement-breakpoint
|
||||
CREATE TABLE "steam_account_credentials" (
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"steam_id" varchar(255) PRIMARY KEY NOT NULL,
|
||||
"refresh_token" text NOT NULL,
|
||||
"expiry" timestamp with time zone NOT NULL,
|
||||
"username" varchar(255) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "friends_list" (
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"steam_id" varchar(255) NOT NULL,
|
||||
"friend_steam_id" varchar(255) NOT NULL,
|
||||
CONSTRAINT "friends_list_steam_id_friend_steam_id_pk" PRIMARY KEY("steam_id","friend_steam_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "members" (
|
||||
"id" char(30) NOT NULL,
|
||||
"team_id" char(30) NOT NULL,
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"user_id" char(30),
|
||||
"steam_id" varchar(255) NOT NULL,
|
||||
"role" "member_role" NOT NULL,
|
||||
CONSTRAINT "members_id_team_id_pk" PRIMARY KEY("id","team_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "steam_accounts" (
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"steam_id" varchar(255) PRIMARY KEY NOT NULL,
|
||||
"user_id" char(30),
|
||||
"status" "steam_status" NOT NULL,
|
||||
"last_synced_at" timestamp with time zone NOT NULL,
|
||||
"real_name" varchar(255),
|
||||
"member_since" timestamp with time zone NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"profile_url" varchar(255),
|
||||
"username" varchar(255) NOT NULL,
|
||||
"avatar_hash" varchar(255) NOT NULL,
|
||||
"limitations" json NOT NULL,
|
||||
CONSTRAINT "idx_steam_username" UNIQUE("username")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "teams" (
|
||||
"id" char(30) PRIMARY KEY NOT NULL,
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"owner_id" char(30) NOT NULL,
|
||||
"invite_code" varchar(10) NOT NULL,
|
||||
"slug" varchar(255) NOT NULL,
|
||||
"max_members" bigint NOT NULL,
|
||||
CONSTRAINT "idx_team_invite_code" UNIQUE("invite_code")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" char(30) PRIMARY KEY NOT NULL,
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"email" varchar(255) NOT NULL,
|
||||
"avatar_url" text,
|
||||
"last_login" timestamp with time zone NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"polar_customer_id" varchar(255),
|
||||
CONSTRAINT "idx_user_email" UNIQUE("email")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DROP TABLE "machine" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "member" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "steam" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "subscription" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "team" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "user" CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "steam_account_credentials" ADD CONSTRAINT "steam_account_credentials_steam_id_steam_accounts_steam_id_fk" FOREIGN KEY ("steam_id") REFERENCES "public"."steam_accounts"("steam_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "friends_list" ADD CONSTRAINT "friends_list_steam_id_steam_accounts_steam_id_fk" FOREIGN KEY ("steam_id") REFERENCES "public"."steam_accounts"("steam_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "friends_list" ADD CONSTRAINT "friends_list_friend_steam_id_steam_accounts_steam_id_fk" FOREIGN KEY ("friend_steam_id") REFERENCES "public"."steam_accounts"("steam_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "members" ADD CONSTRAINT "members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "members" ADD CONSTRAINT "members_steam_id_steam_accounts_steam_id_fk" FOREIGN KEY ("steam_id") REFERENCES "public"."steam_accounts"("steam_id") ON DELETE cascade ON UPDATE restrict;--> statement-breakpoint
|
||||
ALTER TABLE "steam_accounts" ADD CONSTRAINT "steam_accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "teams" ADD CONSTRAINT "teams_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "teams" ADD CONSTRAINT "teams_slug_steam_accounts_username_fk" FOREIGN KEY ("slug") REFERENCES "public"."steam_accounts"("username") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_member_steam_id" ON "members" USING btree ("team_id","steam_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_member_user_id" ON "members" USING btree ("team_id","user_id") WHERE "members"."user_id" is not null;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_team_slug" ON "teams" USING btree ("slug");
|
||||
89
packages/core/migrations/0011_simple_azazel.sql
Normal file
89
packages/core/migrations/0011_simple_azazel.sql
Normal file
@@ -0,0 +1,89 @@
|
||||
CREATE TYPE "public"."compatibility" AS ENUM('high', 'mid', 'low', 'unknown');--> statement-breakpoint
|
||||
CREATE TYPE "public"."category_type" AS ENUM('tag', 'genre', 'publisher', 'developer');--> statement-breakpoint
|
||||
CREATE TYPE "public"."image_type" AS ENUM('heroArt', 'icon', 'logo', 'superHeroArt', 'poster', 'boxArt', 'screenshot', 'background');--> statement-breakpoint
|
||||
CREATE TABLE "base_games" (
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||
"slug" varchar(255) NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"release_date" timestamp with time zone NOT NULL,
|
||||
"size" json NOT NULL,
|
||||
"description" text NOT NULL,
|
||||
"primary_genre" text NOT NULL,
|
||||
"controller_support" text,
|
||||
"compatibility" "compatibility" DEFAULT 'unknown' NOT NULL,
|
||||
"score" numeric(2, 1) NOT NULL,
|
||||
CONSTRAINT "idx_base_games_slug" UNIQUE("slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "categories" (
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"slug" varchar(255) NOT NULL,
|
||||
"type" "category_type" NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
CONSTRAINT "categories_slug_type_pk" PRIMARY KEY("slug","type")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "games" (
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"base_game_id" varchar(255) NOT NULL,
|
||||
"category_slug" varchar(255) NOT NULL,
|
||||
"type" "category_type" NOT NULL,
|
||||
CONSTRAINT "games_base_game_id_category_slug_type_pk" PRIMARY KEY("base_game_id","category_slug","type")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "images" (
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"type" "image_type" NOT NULL,
|
||||
"image_hash" varchar(255) NOT NULL,
|
||||
"base_game_id" varchar(255) NOT NULL,
|
||||
"source_url" text NOT NULL,
|
||||
"position" integer DEFAULT 0 NOT NULL,
|
||||
"file_size" integer NOT NULL,
|
||||
"dimensions" json NOT NULL,
|
||||
"extracted_color" json NOT NULL,
|
||||
CONSTRAINT "images_image_hash_type_base_game_id_position_pk" PRIMARY KEY("image_hash","type","base_game_id","position")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "game_libraries" (
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"base_game_id" varchar(255) NOT NULL,
|
||||
"owner_id" varchar(255) NOT NULL,
|
||||
CONSTRAINT "game_libraries_base_game_id_owner_id_pk" PRIMARY KEY("base_game_id","owner_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "steam_accounts" RENAME COLUMN "steam_id" TO "id";--> statement-breakpoint
|
||||
ALTER TABLE "steam_account_credentials" DROP CONSTRAINT "steam_account_credentials_steam_id_steam_accounts_steam_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "friends_list" DROP CONSTRAINT "friends_list_steam_id_steam_accounts_steam_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "friends_list" DROP CONSTRAINT "friends_list_friend_steam_id_steam_accounts_steam_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "members" DROP CONSTRAINT "members_steam_id_steam_accounts_steam_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "games" ADD CONSTRAINT "games_base_game_id_base_games_id_fk" FOREIGN KEY ("base_game_id") REFERENCES "public"."base_games"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "games" ADD CONSTRAINT "games_categories_fkey" FOREIGN KEY ("category_slug","type") REFERENCES "public"."categories"("slug","type") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "images" ADD CONSTRAINT "images_base_game_id_base_games_id_fk" FOREIGN KEY ("base_game_id") REFERENCES "public"."base_games"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" ADD CONSTRAINT "game_libraries_base_game_id_base_games_id_fk" FOREIGN KEY ("base_game_id") REFERENCES "public"."base_games"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" ADD CONSTRAINT "game_libraries_owner_id_steam_accounts_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_categories_type" ON "categories" USING btree ("type");--> statement-breakpoint
|
||||
CREATE INDEX "idx_games_category_slug" ON "games" USING btree ("category_slug");--> statement-breakpoint
|
||||
CREATE INDEX "idx_games_category_type" ON "games" USING btree ("type");--> statement-breakpoint
|
||||
CREATE INDEX "idx_images_type" ON "images" USING btree ("type");--> statement-breakpoint
|
||||
CREATE INDEX "idx_images_game_id" ON "images" USING btree ("base_game_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_game_libraries_owner_id" ON "game_libraries" USING btree ("owner_id");--> statement-breakpoint
|
||||
ALTER TABLE "steam_account_credentials" ADD CONSTRAINT "steam_account_credentials_steam_id_steam_accounts_id_fk" FOREIGN KEY ("steam_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "friends_list" ADD CONSTRAINT "friends_list_steam_id_steam_accounts_id_fk" FOREIGN KEY ("steam_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "friends_list" ADD CONSTRAINT "friends_list_friend_steam_id_steam_accounts_id_fk" FOREIGN KEY ("friend_steam_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "members" ADD CONSTRAINT "members_steam_id_steam_accounts_id_fk" FOREIGN KEY ("steam_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE restrict;--> statement-breakpoint
|
||||
CREATE INDEX "idx_friends_list_friend_steam_id" ON "friends_list" USING btree ("friend_steam_id");
|
||||
4
packages/core/migrations/0012_glorious_jetstream.sql
Normal file
4
packages/core/migrations/0012_glorious_jetstream.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "games" DROP CONSTRAINT "games_categories_fkey";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "games" ADD CONSTRAINT "games_categories_fkey" FOREIGN KEY ("category_slug","type") REFERENCES "public"."categories"("slug","type") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_games_category_slug_type" ON "games" USING btree ("category_slug","type");
|
||||
3
packages/core/migrations/0013_neat_colleen_wing.sql
Normal file
3
packages/core/migrations/0013_neat_colleen_wing.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
CREATE TYPE "public"."controller_support" AS ENUM('full', 'unknown');--> statement-breakpoint
|
||||
ALTER TABLE "base_games" ALTER COLUMN "controller_support" SET DATA TYPE controller_support;--> statement-breakpoint
|
||||
ALTER TABLE "base_games" ALTER COLUMN "controller_support" SET NOT NULL;
|
||||
1
packages/core/migrations/0014_thin_groot.sql
Normal file
1
packages/core/migrations/0014_thin_groot.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "base_games" ALTER COLUMN "primary_genre" DROP NOT NULL;
|
||||
1
packages/core/migrations/0015_handy_giant_man.sql
Normal file
1
packages/core/migrations/0015_handy_giant_man.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."controller_support" ADD VALUE 'partial' BEFORE 'unknown';
|
||||
4
packages/core/migrations/0016_melted_johnny_storm.sql
Normal file
4
packages/core/migrations/0016_melted_johnny_storm.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "game_libraries" ADD COLUMN "time_acquired" timestamp with time zone NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" ADD COLUMN "last_played" timestamp with time zone NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" ADD COLUMN "total_playtime" integer NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" ADD COLUMN "is_family_shared" boolean NOT NULL;
|
||||
4
packages/core/migrations/0017_zippy_nico_minoru.sql
Normal file
4
packages/core/migrations/0017_zippy_nico_minoru.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "public"."images" ALTER COLUMN "type" SET DATA TYPE text;--> statement-breakpoint
|
||||
DROP TYPE "public"."image_type";--> statement-breakpoint
|
||||
CREATE TYPE "public"."image_type" AS ENUM('heroArt', 'icon', 'logo', 'superHeroArt', 'poster', 'boxArt', 'screenshot', 'backdrop');--> statement-breakpoint
|
||||
ALTER TABLE "public"."images" ALTER COLUMN "type" SET DATA TYPE "public"."image_type" USING "type"::"public"."image_type";
|
||||
4
packages/core/migrations/0018_solid_enchantress.sql
Normal file
4
packages/core/migrations/0018_solid_enchantress.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "public"."images" ALTER COLUMN "type" SET DATA TYPE text;--> statement-breakpoint
|
||||
DROP TYPE "public"."image_type";--> statement-breakpoint
|
||||
CREATE TYPE "public"."image_type" AS ENUM('heroArt', 'icon', 'logo', 'banner', 'poster', 'boxArt', 'screenshot', 'backdrop');--> statement-breakpoint
|
||||
ALTER TABLE "public"."images" ALTER COLUMN "type" SET DATA TYPE "public"."image_type" USING "type"::"public"."image_type";
|
||||
19
packages/core/migrations/0019_charming_namorita.sql
Normal file
19
packages/core/migrations/0019_charming_namorita.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
Unfortunately in current drizzle-kit version we can't automatically get name for primary key.
|
||||
We are working on making it available!
|
||||
|
||||
Meanwhile you can:
|
||||
1. Check pk name in your database, by running
|
||||
SELECT constraint_name FROM information_schema.table_constraints
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'steam_account_credentials'
|
||||
AND constraint_type = 'PRIMARY KEY';
|
||||
2. Uncomment code below and paste pk name manually
|
||||
|
||||
Hope to release this update as soon as possible
|
||||
*/
|
||||
|
||||
-- ALTER TABLE "steam_account_credentials" DROP CONSTRAINT "<constraint_name>";--> statement-breakpoint
|
||||
ALTER TABLE "images" ALTER COLUMN "source_url" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "steam_account_credentials" ADD CONSTRAINT "steam_account_credentials_steam_id_id_pk" PRIMARY KEY("steam_id","id");--> statement-breakpoint
|
||||
ALTER TABLE "steam_account_credentials" ADD COLUMN "id" char(30) NOT NULL;
|
||||
23
packages/core/migrations/0020_vengeful_wallop.sql
Normal file
23
packages/core/migrations/0020_vengeful_wallop.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
ALTER TABLE "steam_account_credentials" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
DROP TABLE "steam_account_credentials" CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" RENAME COLUMN "owner_id" TO "owner_steam_id";--> statement-breakpoint
|
||||
ALTER TABLE "teams" RENAME COLUMN "owner_id" TO "owner_steam_id";--> statement-breakpoint
|
||||
ALTER TABLE "steam_accounts" DROP CONSTRAINT "idx_steam_username";--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" DROP CONSTRAINT "game_libraries_owner_id_steam_accounts_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "teams" DROP CONSTRAINT "teams_owner_id_users_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "teams" DROP CONSTRAINT "teams_slug_steam_accounts_username_fk";
|
||||
--> statement-breakpoint
|
||||
DROP INDEX "idx_team_slug";--> statement-breakpoint
|
||||
DROP INDEX "idx_game_libraries_owner_id";--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" DROP CONSTRAINT "game_libraries_base_game_id_owner_id_pk";--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" ALTER COLUMN "last_played" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" ADD CONSTRAINT "game_libraries_base_game_id_owner_steam_id_pk" PRIMARY KEY("base_game_id","owner_steam_id");--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" ADD CONSTRAINT "game_libraries_owner_steam_id_steam_accounts_id_fk" FOREIGN KEY ("owner_steam_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "teams" ADD CONSTRAINT "teams_owner_steam_id_steam_accounts_id_fk" FOREIGN KEY ("owner_steam_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_game_libraries_owner_id" ON "game_libraries" USING btree ("owner_steam_id");--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" DROP COLUMN "time_acquired";--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" DROP COLUMN "is_family_shared";--> statement-breakpoint
|
||||
ALTER TABLE "steam_accounts" DROP COLUMN "username";--> statement-breakpoint
|
||||
ALTER TABLE "teams" DROP COLUMN "slug";
|
||||
2
packages/core/migrations/0021_real_skreet.sql
Normal file
2
packages/core/migrations/0021_real_skreet.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TYPE "public"."category_type" ADD VALUE 'category';--> statement-breakpoint
|
||||
ALTER TYPE "public"."category_type" ADD VALUE 'franchise';
|
||||
6
packages/core/migrations/0022_clean_living_lightning.sql
Normal file
6
packages/core/migrations/0022_clean_living_lightning.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE "public"."categories" ALTER COLUMN "type" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "public"."games" ALTER COLUMN "type" SET DATA TYPE text;--> statement-breakpoint
|
||||
DROP TYPE "public"."category_type";--> statement-breakpoint
|
||||
CREATE TYPE "public"."category_type" AS ENUM('tag', 'genre', 'publisher', 'developer', 'categorie', 'franchise');--> statement-breakpoint
|
||||
ALTER TABLE "public"."categories" ALTER COLUMN "type" SET DATA TYPE "public"."category_type" USING "type"::"public"."category_type";--> statement-breakpoint
|
||||
ALTER TABLE "public"."games" ALTER COLUMN "type" SET DATA TYPE "public"."category_type" USING "type"::"public"."category_type";
|
||||
2
packages/core/migrations/0023_flawless_steel_serpent.sql
Normal file
2
packages/core/migrations/0023_flawless_steel_serpent.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "base_games" ALTER COLUMN "description" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "base_games" ADD COLUMN "links" text[];
|
||||
1
packages/core/migrations/0024_damp_cerise.sql
Normal file
1
packages/core/migrations/0024_damp_cerise.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "base_games" ALTER COLUMN "links" SET DATA TYPE json;
|
||||
3
packages/core/migrations/0025_bitter_jack_flag.sql
Normal file
3
packages/core/migrations/0025_bitter_jack_flag.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
DROP TABLE "members" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "teams" CASCADE;--> statement-breakpoint
|
||||
DROP TYPE "public"."member_role";
|
||||
651
packages/core/migrations/meta/0010_snapshot.json
Normal file
651
packages/core/migrations/meta/0010_snapshot.json
Normal file
@@ -0,0 +1,651 @@
|
||||
{
|
||||
"id": "56a4d60a-c062-47e5-a97e-625443411ad8",
|
||||
"prevId": "1717c769-cee0-4242-bcbb-9538c80d985c",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.steam_account_credentials": {
|
||||
"name": "steam_account_credentials",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"steam_id": {
|
||||
"name": "steam_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expiry": {
|
||||
"name": "expiry",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"steam_account_credentials_steam_id_steam_accounts_steam_id_fk": {
|
||||
"name": "steam_account_credentials_steam_id_steam_accounts_steam_id_fk",
|
||||
"tableFrom": "steam_account_credentials",
|
||||
"tableTo": "steam_accounts",
|
||||
"columnsFrom": [
|
||||
"steam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"steam_id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.friends_list": {
|
||||
"name": "friends_list",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"steam_id": {
|
||||
"name": "steam_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"friend_steam_id": {
|
||||
"name": "friend_steam_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"friends_list_steam_id_steam_accounts_steam_id_fk": {
|
||||
"name": "friends_list_steam_id_steam_accounts_steam_id_fk",
|
||||
"tableFrom": "friends_list",
|
||||
"tableTo": "steam_accounts",
|
||||
"columnsFrom": [
|
||||
"steam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"steam_id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"friends_list_friend_steam_id_steam_accounts_steam_id_fk": {
|
||||
"name": "friends_list_friend_steam_id_steam_accounts_steam_id_fk",
|
||||
"tableFrom": "friends_list",
|
||||
"tableTo": "steam_accounts",
|
||||
"columnsFrom": [
|
||||
"friend_steam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"steam_id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"friends_list_steam_id_friend_steam_id_pk": {
|
||||
"name": "friends_list_steam_id_friend_steam_id_pk",
|
||||
"columns": [
|
||||
"steam_id",
|
||||
"friend_steam_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.members": {
|
||||
"name": "members",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"team_id": {
|
||||
"name": "team_id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"steam_id": {
|
||||
"name": "steam_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "member_role",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_member_steam_id": {
|
||||
"name": "idx_member_steam_id",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "team_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "steam_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_member_user_id": {
|
||||
"name": "idx_member_user_id",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "team_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"where": "\"members\".\"user_id\" is not null",
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"members_user_id_users_id_fk": {
|
||||
"name": "members_user_id_users_id_fk",
|
||||
"tableFrom": "members",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"members_steam_id_steam_accounts_steam_id_fk": {
|
||||
"name": "members_steam_id_steam_accounts_steam_id_fk",
|
||||
"tableFrom": "members",
|
||||
"tableTo": "steam_accounts",
|
||||
"columnsFrom": [
|
||||
"steam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"steam_id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "restrict"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"members_id_team_id_pk": {
|
||||
"name": "members_id_team_id_pk",
|
||||
"columns": [
|
||||
"id",
|
||||
"team_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.steam_accounts": {
|
||||
"name": "steam_accounts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"steam_id": {
|
||||
"name": "steam_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "steam_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"last_synced_at": {
|
||||
"name": "last_synced_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"real_name": {
|
||||
"name": "real_name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"member_since": {
|
||||
"name": "member_since",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"profile_url": {
|
||||
"name": "profile_url",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"avatar_hash": {
|
||||
"name": "avatar_hash",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"limitations": {
|
||||
"name": "limitations",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"steam_accounts_user_id_users_id_fk": {
|
||||
"name": "steam_accounts_user_id_users_id_fk",
|
||||
"tableFrom": "steam_accounts",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"idx_steam_username": {
|
||||
"name": "idx_steam_username",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"username"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.teams": {
|
||||
"name": "teams",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"owner_id": {
|
||||
"name": "owner_id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"invite_code": {
|
||||
"name": "invite_code",
|
||||
"type": "varchar(10)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"max_members": {
|
||||
"name": "max_members",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_team_slug": {
|
||||
"name": "idx_team_slug",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "slug",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"teams_owner_id_users_id_fk": {
|
||||
"name": "teams_owner_id_users_id_fk",
|
||||
"tableFrom": "teams",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"owner_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"teams_slug_steam_accounts_username_fk": {
|
||||
"name": "teams_slug_steam_accounts_username_fk",
|
||||
"tableFrom": "teams",
|
||||
"tableTo": "steam_accounts",
|
||||
"columnsFrom": [
|
||||
"slug"
|
||||
],
|
||||
"columnsTo": [
|
||||
"username"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"idx_team_invite_code": {
|
||||
"name": "idx_team_invite_code",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"invite_code"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"avatar_url": {
|
||||
"name": "avatar_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"last_login": {
|
||||
"name": "last_login",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"polar_customer_id": {
|
||||
"name": "polar_customer_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"idx_user_email": {
|
||||
"name": "idx_user_email",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.member_role": {
|
||||
"name": "member_role",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"child",
|
||||
"adult"
|
||||
]
|
||||
},
|
||||
"public.steam_status": {
|
||||
"name": "steam_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"online",
|
||||
"offline",
|
||||
"dnd",
|
||||
"playing"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
1235
packages/core/migrations/meta/0011_snapshot.json
Normal file
1235
packages/core/migrations/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1256
packages/core/migrations/meta/0012_snapshot.json
Normal file
1256
packages/core/migrations/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1265
packages/core/migrations/meta/0013_snapshot.json
Normal file
1265
packages/core/migrations/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1265
packages/core/migrations/meta/0014_snapshot.json
Normal file
1265
packages/core/migrations/meta/0014_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1266
packages/core/migrations/meta/0015_snapshot.json
Normal file
1266
packages/core/migrations/meta/0015_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1290
packages/core/migrations/meta/0016_snapshot.json
Normal file
1290
packages/core/migrations/meta/0016_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1290
packages/core/migrations/meta/0017_snapshot.json
Normal file
1290
packages/core/migrations/meta/0017_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1290
packages/core/migrations/meta/0018_snapshot.json
Normal file
1290
packages/core/migrations/meta/0018_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1304
packages/core/migrations/meta/0019_snapshot.json
Normal file
1304
packages/core/migrations/meta/0019_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1158
packages/core/migrations/meta/0020_snapshot.json
Normal file
1158
packages/core/migrations/meta/0020_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1160
packages/core/migrations/meta/0021_snapshot.json
Normal file
1160
packages/core/migrations/meta/0021_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1160
packages/core/migrations/meta/0022_snapshot.json
Normal file
1160
packages/core/migrations/meta/0022_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1166
packages/core/migrations/meta/0023_snapshot.json
Normal file
1166
packages/core/migrations/meta/0023_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1166
packages/core/migrations/meta/0024_snapshot.json
Normal file
1166
packages/core/migrations/meta/0024_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
930
packages/core/migrations/meta/0025_snapshot.json
Normal file
930
packages/core/migrations/meta/0025_snapshot.json
Normal file
@@ -0,0 +1,930 @@
|
||||
{
|
||||
"id": "735d315b-40e1-46c1-814d-0fd3619b65de",
|
||||
"prevId": "d35aa09b-5739-46a5-86f3-3050913dc2f7",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.base_games": {
|
||||
"name": "base_games",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"links": {
|
||||
"name": "links",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"release_date": {
|
||||
"name": "release_date",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"size": {
|
||||
"name": "size",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"primary_genre": {
|
||||
"name": "primary_genre",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"controller_support": {
|
||||
"name": "controller_support",
|
||||
"type": "controller_support",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"compatibility": {
|
||||
"name": "compatibility",
|
||||
"type": "compatibility",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'unknown'"
|
||||
},
|
||||
"score": {
|
||||
"name": "score",
|
||||
"type": "numeric(2, 1)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"idx_base_games_slug": {
|
||||
"name": "idx_base_games_slug",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"slug"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.categories": {
|
||||
"name": "categories",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "category_type",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_categories_type": {
|
||||
"name": "idx_categories_type",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "type",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"categories_slug_type_pk": {
|
||||
"name": "categories_slug_type_pk",
|
||||
"columns": [
|
||||
"slug",
|
||||
"type"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.friends_list": {
|
||||
"name": "friends_list",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"steam_id": {
|
||||
"name": "steam_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"friend_steam_id": {
|
||||
"name": "friend_steam_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_friends_list_friend_steam_id": {
|
||||
"name": "idx_friends_list_friend_steam_id",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "friend_steam_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"friends_list_steam_id_steam_accounts_id_fk": {
|
||||
"name": "friends_list_steam_id_steam_accounts_id_fk",
|
||||
"tableFrom": "friends_list",
|
||||
"tableTo": "steam_accounts",
|
||||
"columnsFrom": [
|
||||
"steam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"friends_list_friend_steam_id_steam_accounts_id_fk": {
|
||||
"name": "friends_list_friend_steam_id_steam_accounts_id_fk",
|
||||
"tableFrom": "friends_list",
|
||||
"tableTo": "steam_accounts",
|
||||
"columnsFrom": [
|
||||
"friend_steam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"friends_list_steam_id_friend_steam_id_pk": {
|
||||
"name": "friends_list_steam_id_friend_steam_id_pk",
|
||||
"columns": [
|
||||
"steam_id",
|
||||
"friend_steam_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.games": {
|
||||
"name": "games",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"base_game_id": {
|
||||
"name": "base_game_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"category_slug": {
|
||||
"name": "category_slug",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "category_type",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_games_category_slug": {
|
||||
"name": "idx_games_category_slug",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "category_slug",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_games_category_type": {
|
||||
"name": "idx_games_category_type",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "type",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_games_category_slug_type": {
|
||||
"name": "idx_games_category_slug_type",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "category_slug",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "type",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"games_base_game_id_base_games_id_fk": {
|
||||
"name": "games_base_game_id_base_games_id_fk",
|
||||
"tableFrom": "games",
|
||||
"tableTo": "base_games",
|
||||
"columnsFrom": [
|
||||
"base_game_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"games_categories_fkey": {
|
||||
"name": "games_categories_fkey",
|
||||
"tableFrom": "games",
|
||||
"tableTo": "categories",
|
||||
"columnsFrom": [
|
||||
"category_slug",
|
||||
"type"
|
||||
],
|
||||
"columnsTo": [
|
||||
"slug",
|
||||
"type"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"games_base_game_id_category_slug_type_pk": {
|
||||
"name": "games_base_game_id_category_slug_type_pk",
|
||||
"columns": [
|
||||
"base_game_id",
|
||||
"category_slug",
|
||||
"type"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.images": {
|
||||
"name": "images",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "image_type",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"image_hash": {
|
||||
"name": "image_hash",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"base_game_id": {
|
||||
"name": "base_game_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"source_url": {
|
||||
"name": "source_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"position": {
|
||||
"name": "position",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"file_size": {
|
||||
"name": "file_size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"dimensions": {
|
||||
"name": "dimensions",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"extracted_color": {
|
||||
"name": "extracted_color",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_images_type": {
|
||||
"name": "idx_images_type",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "type",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_images_game_id": {
|
||||
"name": "idx_images_game_id",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "base_game_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"images_base_game_id_base_games_id_fk": {
|
||||
"name": "images_base_game_id_base_games_id_fk",
|
||||
"tableFrom": "images",
|
||||
"tableTo": "base_games",
|
||||
"columnsFrom": [
|
||||
"base_game_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"images_image_hash_type_base_game_id_position_pk": {
|
||||
"name": "images_image_hash_type_base_game_id_position_pk",
|
||||
"columns": [
|
||||
"image_hash",
|
||||
"type",
|
||||
"base_game_id",
|
||||
"position"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.game_libraries": {
|
||||
"name": "game_libraries",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"base_game_id": {
|
||||
"name": "base_game_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"owner_steam_id": {
|
||||
"name": "owner_steam_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"last_played": {
|
||||
"name": "last_played",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"total_playtime": {
|
||||
"name": "total_playtime",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_game_libraries_owner_id": {
|
||||
"name": "idx_game_libraries_owner_id",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "owner_steam_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"game_libraries_base_game_id_base_games_id_fk": {
|
||||
"name": "game_libraries_base_game_id_base_games_id_fk",
|
||||
"tableFrom": "game_libraries",
|
||||
"tableTo": "base_games",
|
||||
"columnsFrom": [
|
||||
"base_game_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"game_libraries_owner_steam_id_steam_accounts_id_fk": {
|
||||
"name": "game_libraries_owner_steam_id_steam_accounts_id_fk",
|
||||
"tableFrom": "game_libraries",
|
||||
"tableTo": "steam_accounts",
|
||||
"columnsFrom": [
|
||||
"owner_steam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"game_libraries_base_game_id_owner_steam_id_pk": {
|
||||
"name": "game_libraries_base_game_id_owner_steam_id_pk",
|
||||
"columns": [
|
||||
"base_game_id",
|
||||
"owner_steam_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.steam_accounts": {
|
||||
"name": "steam_accounts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "steam_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"last_synced_at": {
|
||||
"name": "last_synced_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"real_name": {
|
||||
"name": "real_name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"member_since": {
|
||||
"name": "member_since",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"profile_url": {
|
||||
"name": "profile_url",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"avatar_hash": {
|
||||
"name": "avatar_hash",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"limitations": {
|
||||
"name": "limitations",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"steam_accounts_user_id_users_id_fk": {
|
||||
"name": "steam_accounts_user_id_users_id_fk",
|
||||
"tableFrom": "steam_accounts",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"avatar_url": {
|
||||
"name": "avatar_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"last_login": {
|
||||
"name": "last_login",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"polar_customer_id": {
|
||||
"name": "polar_customer_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"idx_user_email": {
|
||||
"name": "idx_user_email",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.compatibility": {
|
||||
"name": "compatibility",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"high",
|
||||
"mid",
|
||||
"low",
|
||||
"unknown"
|
||||
]
|
||||
},
|
||||
"public.controller_support": {
|
||||
"name": "controller_support",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"full",
|
||||
"partial",
|
||||
"unknown"
|
||||
]
|
||||
},
|
||||
"public.category_type": {
|
||||
"name": "category_type",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"tag",
|
||||
"genre",
|
||||
"publisher",
|
||||
"developer",
|
||||
"categorie",
|
||||
"franchise"
|
||||
]
|
||||
},
|
||||
"public.image_type": {
|
||||
"name": "image_type",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"heroArt",
|
||||
"icon",
|
||||
"logo",
|
||||
"banner",
|
||||
"poster",
|
||||
"boxArt",
|
||||
"screenshot",
|
||||
"backdrop"
|
||||
]
|
||||
},
|
||||
"public.steam_status": {
|
||||
"name": "steam_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"online",
|
||||
"offline",
|
||||
"dnd",
|
||||
"playing"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,118 @@
|
||||
"when": 1744651817581,
|
||||
"tag": "0009_luxuriant_wraith",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1746726715456,
|
||||
"tag": "0010_certain_dust",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1746904821461,
|
||||
"tag": "0011_simple_azazel",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1746905730079,
|
||||
"tag": "0012_glorious_jetstream",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1746925065142,
|
||||
"tag": "0013_neat_colleen_wing",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1746926498096,
|
||||
"tag": "0014_thin_groot",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1746928882281,
|
||||
"tag": "0015_handy_giant_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1747032794033,
|
||||
"tag": "0016_melted_johnny_storm",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1747034424687,
|
||||
"tag": "0017_zippy_nico_minoru",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1747073173196,
|
||||
"tag": "0018_solid_enchantress",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1747202158003,
|
||||
"tag": "0019_charming_namorita",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "7",
|
||||
"when": 1747795508868,
|
||||
"tag": "0020_vengeful_wallop",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "7",
|
||||
"when": 1747975397543,
|
||||
"tag": "0021_real_skreet",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 22,
|
||||
"version": "7",
|
||||
"when": 1748099972605,
|
||||
"tag": "0022_clean_living_lightning",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "7",
|
||||
"when": 1748411845939,
|
||||
"tag": "0023_flawless_steel_serpent",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 24,
|
||||
"version": "7",
|
||||
"when": 1748414049463,
|
||||
"tag": "0024_damp_cerise",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 25,
|
||||
"version": "7",
|
||||
"when": 1748845818197,
|
||||
"tag": "0025_bitter_jack_flag",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -15,26 +15,35 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node20": "^20.1.4",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"@types/xml2js": "^0.4.14",
|
||||
"aws-iot-device-sdk-v2": "^1.21.1",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"loops": "^3.4.1",
|
||||
"mqtt": "^5.10.3",
|
||||
"remeda": "^2.21.2",
|
||||
"ulid": "^2.3.0",
|
||||
"uuid": "^11.0.3",
|
||||
"zod": "^3.24.1",
|
||||
"zod-openapi": "^4.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-iot-data-plane": "^3.758.0",
|
||||
"@aws-sdk/client-rds-data": "^3.758.0",
|
||||
"@aws-sdk/client-sesv2": "^3.753.0",
|
||||
"@instantdb/admin": "^0.17.7",
|
||||
"@openauthjs/openauth": "*",
|
||||
"@openauthjs/openevent": "^0.0.27",
|
||||
"@polar-sh/sdk": "^0.26.1",
|
||||
"drizzle-kit": "^0.30.5",
|
||||
"drizzle-orm": "^0.40.0",
|
||||
"postgres": "^3.4.5"
|
||||
"drizzle-zod": "^0.7.1",
|
||||
"fast-average-color": "^9.5.0",
|
||||
"lru-cache": "^11.1.0",
|
||||
"p-limit": "^6.2.0",
|
||||
"pixelmatch": "^7.1.0",
|
||||
"pngjs": "^7.0.0",
|
||||
"postgres": "^3.4.5",
|
||||
"sanitize-html": "^2.16.0",
|
||||
"sharp": "^0.34.1",
|
||||
"steam-session": "*",
|
||||
"xml2js": "^0.6.2"
|
||||
}
|
||||
}
|
||||
47
packages/core/src/account/index.ts
Normal file
47
packages/core/src/account/index.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { z } from "zod"
|
||||
import { User } from "../user";
|
||||
import { Steam } from "../steam";
|
||||
import { Actor } from "../actor";
|
||||
import { Examples } from "../examples";
|
||||
import { ErrorCodes, VisibleError } from "../error";
|
||||
|
||||
export namespace Account {
|
||||
export const Info =
|
||||
User.Info
|
||||
.extend({
|
||||
profiles: Steam.Info
|
||||
.array()
|
||||
.openapi({
|
||||
description: "The Steam accounts this user owns",
|
||||
example: [Examples.SteamAccount]
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Account",
|
||||
description: "Represents an account's information stored on Nestri",
|
||||
example: { ...Examples.User, profiles: [Examples.SteamAccount] },
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const list = async (): Promise<Info> => {
|
||||
const [userResult, steamResult] =
|
||||
await Promise.allSettled([
|
||||
User.fromID(Actor.userID()),
|
||||
Steam.list()
|
||||
])
|
||||
|
||||
if (userResult.status === "rejected" || !userResult.value)
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
"User not found",
|
||||
);
|
||||
|
||||
return {
|
||||
...userResult.value,
|
||||
profiles: steamResult.status === "rejected" ? [] : steamResult.value
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,142 +1,130 @@
|
||||
import { z } from "zod";
|
||||
import { eq } from "./drizzle";
|
||||
import { ErrorCodes, VisibleError } from "./error";
|
||||
import { Log } from "./utils";
|
||||
import { createContext } from "./context";
|
||||
import { UserFlags, userTable } from "./user/user.sql";
|
||||
import { useTransaction } from "./drizzle/transaction";
|
||||
import { ErrorCodes, VisibleError } from "./error";
|
||||
|
||||
export const PublicActor = z.object({
|
||||
type: z.literal("public"),
|
||||
properties: z.object({}),
|
||||
});
|
||||
export type PublicActor = z.infer<typeof PublicActor>;
|
||||
export namespace Actor {
|
||||
|
||||
export const UserActor = z.object({
|
||||
type: z.literal("user"),
|
||||
properties: z.object({
|
||||
userID: z.string(),
|
||||
email: z.string().nonempty(),
|
||||
}),
|
||||
});
|
||||
export type UserActor = z.infer<typeof UserActor>;
|
||||
|
||||
export const MemberActor = z.object({
|
||||
type: z.literal("member"),
|
||||
properties: z.object({
|
||||
memberID: z.string(),
|
||||
teamID: z.string(),
|
||||
}),
|
||||
});
|
||||
export type MemberActor = z.infer<typeof MemberActor>;
|
||||
|
||||
export const SystemActor = z.object({
|
||||
type: z.literal("system"),
|
||||
properties: z.object({
|
||||
teamID: z.string(),
|
||||
}),
|
||||
});
|
||||
export type SystemActor = z.infer<typeof SystemActor>;
|
||||
|
||||
export const MachineActor = z.object({
|
||||
type: z.literal("machine"),
|
||||
properties: z.object({
|
||||
fingerprint: z.string(),
|
||||
machineID: z.string(),
|
||||
}),
|
||||
});
|
||||
export type MachineActor = z.infer<typeof MachineActor>;
|
||||
|
||||
export const Actor = z.discriminatedUnion("type", [
|
||||
MemberActor,
|
||||
UserActor,
|
||||
PublicActor,
|
||||
SystemActor,
|
||||
MachineActor
|
||||
]);
|
||||
export type Actor = z.infer<typeof Actor>;
|
||||
|
||||
export const ActorContext = createContext<Actor>("actor");
|
||||
|
||||
export const useActor = ActorContext.use;
|
||||
export const withActor = ActorContext.with;
|
||||
|
||||
/**
|
||||
* Retrieves the user ID of the current actor.
|
||||
*
|
||||
* This function accesses the actor context and returns the `userID` if the current
|
||||
* actor is of type "user". If the actor is not a user, it throws a `VisibleError`
|
||||
* with an authentication error code, indicating that the caller is not authorized
|
||||
* to access user-specific resources.
|
||||
*
|
||||
* @throws {VisibleError} When the current actor is not of type "user".
|
||||
*/
|
||||
export function useUserID() {
|
||||
const actor = ActorContext.use();
|
||||
if (actor.type === "user") return actor.properties.userID;
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`You don't have permission to access this resource`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the properties of the current user actor.
|
||||
*
|
||||
* This function obtains the current actor from the context and returns its properties if the actor is identified as a user.
|
||||
* If the actor is not of type "user", it throws a {@link VisibleError} with an authentication error code,
|
||||
* indicating that the user is not authorized to access user-specific resources.
|
||||
*
|
||||
* @returns The properties of the current user actor, typically including user-specific details such as userID and email.
|
||||
* @throws {VisibleError} If the current actor is not a user.
|
||||
*/
|
||||
export function useUser() {
|
||||
const actor = ActorContext.use();
|
||||
if (actor.type === "user") return actor.properties;
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`You don't have permission to access this resource`,
|
||||
);
|
||||
}
|
||||
|
||||
export function assertActor<T extends Actor["type"]>(type: T) {
|
||||
const actor = useActor();
|
||||
if (actor.type !== type) {
|
||||
throw new Error(`Expected actor type ${type}, got ${actor.type}`);
|
||||
export interface User {
|
||||
type: "user";
|
||||
properties: {
|
||||
userID: string;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
return actor as Extract<Actor, { type: T }>;
|
||||
}
|
||||
export interface Steam {
|
||||
type: "steam";
|
||||
properties: {
|
||||
steamID: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current actor's team ID.
|
||||
*
|
||||
* @returns The team ID associated with the current actor.
|
||||
* @throws {VisibleError} If the current actor does not have a {@link teamID} property.
|
||||
*/
|
||||
export function useTeam() {
|
||||
const actor = useActor();
|
||||
export interface Machine {
|
||||
type: "machine";
|
||||
properties: {
|
||||
machineID: string;
|
||||
fingerprint: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Token {
|
||||
type: "member";
|
||||
properties: {
|
||||
userID: string;
|
||||
steamID: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Public {
|
||||
type: "public";
|
||||
properties: {};
|
||||
}
|
||||
|
||||
export type Info = User | Public | Token | Machine | Steam;
|
||||
|
||||
export const Context = createContext<Info>();
|
||||
|
||||
export function userID() {
|
||||
const actor = Context.use();
|
||||
if ("userID" in actor.properties) return actor.properties.userID;
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`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;
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`You don't have permission to access this resource.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function user() {
|
||||
const actor = Context.use();
|
||||
if (actor.type == "user") return actor.properties;
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`You don't have permission to access this resource.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function teamID() {
|
||||
const actor = Context.use();
|
||||
if ("teamID" in actor.properties) return actor.properties.teamID;
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`Expected actor to have teamID`
|
||||
`You don't have permission to access this resource.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the fingerprint of the current actor if the actor has a machine identity.
|
||||
*
|
||||
* @returns The fingerprint of the current machine actor.
|
||||
* @throws {VisibleError} If the current actor does not have a machine identity.
|
||||
*/
|
||||
export function useMachine() {
|
||||
const actor = useActor();
|
||||
if ("machineID" in actor.properties) return actor.properties.fingerprint;
|
||||
export function fingerprint() {
|
||||
const actor = Context.use();
|
||||
if ("fingerprint" in actor.properties) return actor.properties.fingerprint;
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`Expected actor to have fingerprint`
|
||||
`You don't have permission to access this resource.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function use() {
|
||||
try {
|
||||
return Context.use();
|
||||
} catch {
|
||||
return { type: "public", properties: {} } as Public;
|
||||
}
|
||||
}
|
||||
|
||||
export function assert<T extends Info["type"]>(type: T) {
|
||||
const actor = use();
|
||||
if (actor.type !== type)
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`Actor is not "${type}"`,
|
||||
);
|
||||
return actor as Extract<Info, { type: T }>;
|
||||
}
|
||||
|
||||
export function provide<
|
||||
T extends Info["type"],
|
||||
Next extends (...args: any) => any,
|
||||
>(type: T, properties: Extract<Info, { type: T }>["properties"], fn: Next) {
|
||||
return Context.provide({ type, properties } as any, () =>
|
||||
Log.provide(
|
||||
{
|
||||
actor: type,
|
||||
...properties,
|
||||
},
|
||||
fn,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
44
packages/core/src/base-game/base-game.sql.ts
Normal file
44
packages/core/src/base-game/base-game.sql.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
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 ControllerEnum = pgEnum("controller_support", ["full", "partial", "unknown"])
|
||||
|
||||
export const Size =
|
||||
z.object({
|
||||
downloadSize: z.number().positive().int(),
|
||||
sizeOnDisk: z.number().positive().int()
|
||||
});
|
||||
|
||||
export const Links = z.string().array();
|
||||
|
||||
export type Size = z.infer<typeof Size>;
|
||||
export type Links = z.infer<typeof Links>;
|
||||
|
||||
export const baseGamesTable = pgTable(
|
||||
"base_games",
|
||||
{
|
||||
...timestamps,
|
||||
id: varchar("id", { length: 255 })
|
||||
.primaryKey()
|
||||
.notNull(),
|
||||
links: json("links").$type<Links>(),
|
||||
slug: varchar("slug", { length: 255 })
|
||||
.notNull(),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
releaseDate: utc("release_date").notNull(),
|
||||
size: json("size").$type<Size>().notNull(),
|
||||
primaryGenre: text("primary_genre"),
|
||||
controllerSupport: ControllerEnum("controller_support").notNull(),
|
||||
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),
|
||||
]
|
||||
)
|
||||
162
packages/core/src/base-game/index.ts
Normal file
162
packages/core/src/base-game/index.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import { createEvent } from "../event";
|
||||
import { eq, isNull, and } from "drizzle-orm";
|
||||
import { ImageTypeEnum } from "../images/images.sql";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
import { CompatibilityEnum, baseGamesTable, Size, ControllerEnum, Links } from "./base-game.sql";
|
||||
|
||||
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().nullable().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
|
||||
}),
|
||||
links: Links
|
||||
.nullable()
|
||||
.openapi({
|
||||
description: "The social links of this game",
|
||||
example: Examples.BaseGame.links
|
||||
}),
|
||||
primaryGenre: z.string().nullable().openapi({
|
||||
description: "The main category or genre that best represents the game's content and gameplay style",
|
||||
example: Examples.BaseGame.primaryGenre
|
||||
}),
|
||||
controllerSupport: z.enum(ControllerEnum.enumValues).openapi({
|
||||
description: "Indicates the level of gamepad/controller compatibility: 'Full', 'Partial', or 'Unkown' 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 Events = {
|
||||
New: createEvent(
|
||||
"new_image.save",
|
||||
z.object({
|
||||
appID: Info.shape.id,
|
||||
url: z.string().url(),
|
||||
type: z.enum(ImageTypeEnum.enumValues)
|
||||
}),
|
||||
),
|
||||
NewBoxArt: createEvent(
|
||||
"new_box_art_image.save",
|
||||
z.object({
|
||||
appID: Info.shape.id,
|
||||
logoUrl: z.string().url(),
|
||||
backgroundUrl: z.string().url(),
|
||||
}),
|
||||
),
|
||||
NewHeroArt: createEvent(
|
||||
"new_hero_art_image.save",
|
||||
z.object({
|
||||
appID: Info.shape.id,
|
||||
backdropUrl: z.string().url(),
|
||||
screenshots: z.string().url().array(),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
22
packages/core/src/categories/categories.sql.ts
Normal file
22
packages/core/src/categories/categories.sql.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { timestamps } from "../drizzle/types";
|
||||
import { index, pgEnum, pgTable, primaryKey, text, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
// Intentional grammatical error on category
|
||||
export const CategoryTypeEnum = pgEnum("category_type", ["tag", "genre", "publisher", "developer", "categorie", "franchise"])
|
||||
|
||||
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),
|
||||
]
|
||||
)
|
||||
128
packages/core/src/categories/index.ts
Normal file
128
packages/core/src/categories/index.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Examples } from "../examples";
|
||||
import { eq, isNull, and } from "drizzle-orm";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { categoriesTable } from "./categories.sql";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
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
|
||||
}),
|
||||
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 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 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))
|
||||
|
||||
if (result) return result.slug
|
||||
|
||||
await tx
|
||||
.insert(categoriesTable)
|
||||
.values(input)
|
||||
.onConflictDoUpdate({
|
||||
target: [categoriesTable.slug, categoriesTable.type],
|
||||
set: { timeDeleted: null }
|
||||
})
|
||||
|
||||
return input.slug
|
||||
})
|
||||
)
|
||||
|
||||
export const get = fn(
|
||||
InputInfo.pick({ slug: true, type: true }),
|
||||
(input) =>
|
||||
useTransaction((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(categoriesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(categoriesTable.slug, input.slug),
|
||||
eq(categoriesTable.type, input.type),
|
||||
isNull(categoriesTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.execute()
|
||||
.then(rows => serialize(rows))
|
||||
)
|
||||
)
|
||||
|
||||
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: []
|
||||
})
|
||||
}
|
||||
}
|
||||
316
packages/core/src/client/index.ts
Normal file
316
packages/core/src/client/index.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import type {
|
||||
Shot,
|
||||
AppInfo,
|
||||
ImageInfo,
|
||||
ImageType,
|
||||
SteamAccount,
|
||||
GameTagsResponse,
|
||||
GameDetailsResponse,
|
||||
SteamAppDataResponse,
|
||||
SteamOwnedGamesResponse,
|
||||
SteamPlayerBansResponse,
|
||||
SteamFriendsListResponse,
|
||||
SteamPlayerSummaryResponse,
|
||||
SteamStoreResponse,
|
||||
} from "./types";
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Resource } from "sst";
|
||||
import { Steam } from "./steam";
|
||||
import { Utils } from "./utils";
|
||||
import { ImageTypeEnum } from "../images/images.sql";
|
||||
|
||||
export namespace Client {
|
||||
export const getUserLibrary = fn(
|
||||
z.string(),
|
||||
async (steamID) =>
|
||||
await Utils.fetchApi<SteamOwnedGamesResponse>(`https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?key=${Resource.SteamApiKey.value}&steamid=${steamID}&include_appinfo=1&format=json&include_played_free_games=1&skip_unvetted_apps=0`)
|
||||
)
|
||||
|
||||
export const getFriendsList = fn(
|
||||
z.string(),
|
||||
async (steamID) =>
|
||||
await Utils.fetchApi<SteamFriendsListResponse>(`https://api.steampowered.com/ISteamUser/GetFriendList/v0001/?key=${Resource.SteamApiKey.value}&steamid=${steamID}&relationship=friend`)
|
||||
);
|
||||
|
||||
export const getUserInfo = fn(
|
||||
z.string().array(),
|
||||
async (steamIDs) => {
|
||||
const [userInfo, banInfo, profileInfo] = await Promise.all([
|
||||
Utils.fetchApi<SteamPlayerSummaryResponse>(`https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=${Resource.SteamApiKey.value}&steamids=${steamIDs.join(",")}`),
|
||||
Utils.fetchApi<SteamPlayerBansResponse>(`https://api.steampowered.com/ISteamUser/GetPlayerBans/v1/?key=${Resource.SteamApiKey.value}&steamids=${steamIDs.join(",")}`),
|
||||
Utils.fetchProfilesInfo(steamIDs)
|
||||
])
|
||||
|
||||
// Create a map of bans by steamID for fast lookup
|
||||
const bansBySteamID = new Map(
|
||||
banInfo.players.map((b) => [b.SteamId, b])
|
||||
);
|
||||
|
||||
// Map userInfo.players to your desired output using Promise.allSettled
|
||||
// to prevent one error from closing down the whole pipeline
|
||||
const steamAccounts = await Promise.allSettled(
|
||||
userInfo.response.players.map(async (player) => {
|
||||
const ban = bansBySteamID.get(player.steamid);
|
||||
const info = profileInfo.get(player.steamid);
|
||||
|
||||
if (!info) {
|
||||
throw new Error(`[userInfo] profile info missing for ${player.steamid}`)
|
||||
}
|
||||
|
||||
if ('error' in info) {
|
||||
throw new Error(`error handling profile info for: ${player.steamid}:${info.error}`)
|
||||
} else {
|
||||
return {
|
||||
id: player.steamid,
|
||||
name: player.personaname,
|
||||
realName: player.realname ?? null,
|
||||
steamMemberSince: new Date(player.timecreated * 1000),
|
||||
avatarHash: player.avatarhash,
|
||||
limitations: {
|
||||
isLimited: info.isLimited,
|
||||
privacyState: info.privacyState,
|
||||
isVacBanned: ban?.VACBanned ?? false,
|
||||
tradeBanState: ban?.EconomyBan ?? "none",
|
||||
visibilityState: player.communityvisibilitystate,
|
||||
},
|
||||
lastSyncedAt: new Date(),
|
||||
profileUrl: player.profileurl,
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
steamAccounts
|
||||
.filter(result => result.status === 'rejected')
|
||||
.forEach(result => console.warn('[userInfo] failed:', (result as PromiseRejectedResult).reason))
|
||||
|
||||
return steamAccounts.filter(result => result.status === "fulfilled").map(result => (result as PromiseFulfilledResult<SteamAccount>).value)
|
||||
})
|
||||
|
||||
export const getAppInfo = fn(
|
||||
z.string(),
|
||||
async (appid) => {
|
||||
try {
|
||||
const info = await Promise.all([
|
||||
Utils.fetchApi<SteamAppDataResponse>(`https://api.steamcmd.net/v1/info/${appid}`),
|
||||
Utils.fetchApi<SteamStoreResponse>(`https://api.steampowered.com/IStoreBrowseService/GetItems/v1/?key=${Resource.SteamApiKey.value}&input_json={"ids":[{"appid":"${appid}"}],"context":{"language":"english","country_code":"US","steam_realm":"1"},"data_request":{"include_assets":true,"include_release":true,"include_platforms":true,"include_all_purchase_options":true,"include_screenshots":true,"include_trailers":true,"include_ratings":true,"include_tag_count":"40","include_reviews":true,"include_basic_info":true,"include_supported_languages":true,"include_full_description":true,"include_included_items":true,"include_assets_without_overrides":true,"apply_user_filters":true,"include_links":true}}`),
|
||||
]);
|
||||
|
||||
const cmd = info[0].data[appid]
|
||||
const store = info[1].response.store_items[0]
|
||||
|
||||
if (!cmd) {
|
||||
throw new Error(`App data not found for appid: ${appid}`)
|
||||
}
|
||||
|
||||
if (!store || store.success !== 1) {
|
||||
throw new Error(`Could not get store information or appid: ${appid}`)
|
||||
}
|
||||
|
||||
const tags = store.tagids
|
||||
.map(id => Steam.tags[id.toString() as keyof typeof Steam.tags])
|
||||
.filter((name): name is string => typeof name === 'string')
|
||||
|
||||
const publishers = store.basic_info.publishers
|
||||
.map(i => i.name)
|
||||
|
||||
const developers = store.basic_info.developers
|
||||
.map(i => i.name)
|
||||
|
||||
const franchises = store.basic_info.franchises
|
||||
?.map(i => i.name)
|
||||
|
||||
const genres = cmd?.common.genres &&
|
||||
Object.keys(cmd?.common.genres)
|
||||
.map(id => Steam.genres[id.toString() as keyof typeof Steam.genres])
|
||||
.filter((name): name is string => typeof name === 'string')
|
||||
|
||||
const categories = [
|
||||
...(store.categories?.controller_categoryids?.map(i => Steam.categories[i.toString() as keyof typeof Steam.categories]) ?? []),
|
||||
...(store.categories?.supported_player_categoryids?.map(i => Steam.categories[i.toString() as keyof typeof Steam.categories]) ?? [])
|
||||
].filter((name): name is string => typeof name === 'string')
|
||||
|
||||
const assetUrls = Utils.getAssetUrls(cmd?.common.library_assets_full, appid, cmd?.common.header_image.english);
|
||||
|
||||
const screenshots = store.screenshots.all_ages_screenshots?.map(i => `https://shared.cloudflare.steamstatic.com/store_item_assets/${i.filename}`) ?? [];
|
||||
|
||||
const icon = `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${appid}/${cmd?.common.icon}.jpg`;
|
||||
|
||||
const data: AppInfo = {
|
||||
id: appid,
|
||||
name: cmd?.common.name.trim(),
|
||||
tags: Utils.createType(tags, "tag"),
|
||||
images: { screenshots, icon, ...assetUrls },
|
||||
size: Utils.getPublicDepotSizes(cmd?.depots!),
|
||||
slug: Utils.createSlug(cmd?.common.name.trim()),
|
||||
publishers: Utils.createType(publishers, "publisher"),
|
||||
developers: Utils.createType(developers, "developer"),
|
||||
categories: Utils.createType(categories, "categorie"),
|
||||
links: store.links ? store.links.map(i => i.url) : null,
|
||||
genres: genres ? Utils.createType(genres, "genre") : [],
|
||||
franchises: franchises ? Utils.createType(franchises, "franchise") : [],
|
||||
description: store.basic_info.short_description ? Utils.cleanDescription(store.basic_info.short_description) : null,
|
||||
controllerSupport: cmd?.common.controller_support ?? "unknown" as any,
|
||||
releaseDate: new Date(Number(cmd?.common.steam_release_date) * 1000),
|
||||
primaryGenre: !!cmd?.common.primary_genre && Steam.genres[cmd?.common.primary_genre as keyof typeof Steam.genres] ? Steam.genres[cmd?.common.primary_genre as keyof typeof Steam.genres] : null,
|
||||
compatibility: store?.platforms.steam_os_compat_category ? Utils.compatibilityType(store?.platforms.steam_os_compat_category.toString() as any).toLowerCase() : "unknown" as any,
|
||||
score: Utils.estimateRatingFromSummary(store.reviews.summary_filtered.review_count, store.reviews.summary_filtered.percent_positive)
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (err) {
|
||||
console.log(`Error handling: ${appid}`)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const getImageUrls = fn(
|
||||
z.string(),
|
||||
async (appid) => {
|
||||
const [appData, details] = await Promise.all([
|
||||
Utils.fetchApi<SteamAppDataResponse>(`https://api.steamcmd.net/v1/info/${appid}`),
|
||||
Utils.fetchApi<GameDetailsResponse>(
|
||||
`https://store.steampowered.com/apphover/${appid}?full=1&review_score_preference=1&pagev6=true&json=1`
|
||||
),
|
||||
]);
|
||||
|
||||
const game = appData.data[appid]?.common;
|
||||
if (!game) throw new Error('Game info missing');
|
||||
|
||||
// 2. Prepare URLs
|
||||
const screenshots = Utils.getScreenshotUrls(details.rgScreenshots || []);
|
||||
const assetUrls = Utils.getAssetUrls(game.library_assets_full, appid, game.header_image.english);
|
||||
const icon = `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${appid}/${game.icon}.jpg`;
|
||||
|
||||
return { screenshots, icon, ...assetUrls }
|
||||
}
|
||||
)
|
||||
|
||||
export const getImageInfo = fn(
|
||||
z.object({
|
||||
type: z.enum(ImageTypeEnum.enumValues),
|
||||
url: z.string()
|
||||
}),
|
||||
async (input) =>
|
||||
Utils.fetchBuffer(input.url)
|
||||
.then(buf => Utils.getImageMetadata(buf))
|
||||
.then(meta => ({ ...meta, position: 0, sourceUrl: input.url, type: input.type } as ImageInfo))
|
||||
)
|
||||
|
||||
export const createBoxArt = fn(
|
||||
z.object({
|
||||
backgroundUrl: z.string(),
|
||||
logoUrl: z.string(),
|
||||
}),
|
||||
async (input) =>
|
||||
Utils.createBoxArtBuffer(input.logoUrl, input.backgroundUrl)
|
||||
.then(buf => Utils.getImageMetadata(buf))
|
||||
.then(meta => ({ ...meta, position: 0, sourceUrl: null, type: 'boxArt' as const }) as ImageInfo)
|
||||
)
|
||||
|
||||
export const createHeroArt = fn(
|
||||
z.object({
|
||||
screenshots: z.string().array(),
|
||||
backdropUrl: z.string()
|
||||
}),
|
||||
async (input) => {
|
||||
// Download screenshot buffers in parallel
|
||||
const shots: Shot[] = await Promise.all(
|
||||
input.screenshots.map(async url => ({ url, buffer: await Utils.fetchBuffer(url) }))
|
||||
);
|
||||
|
||||
const baselineBuffer = await Utils.fetchBuffer(input.backdropUrl);
|
||||
|
||||
// 4. Score screenshots (or pick single)
|
||||
const scores =
|
||||
shots.length === 1
|
||||
? [{ url: shots[0].url, score: 0 }]
|
||||
: (await Utils.rankScreenshots(baselineBuffer, shots, {
|
||||
threshold: 0.08,
|
||||
}))
|
||||
|
||||
// Build url->rank map
|
||||
const rankMap = new Map<string, number>();
|
||||
scores.forEach((s, i) => rankMap.set(s.url, i));
|
||||
|
||||
// 5. Create tasks for all images
|
||||
const tasks: Array<Promise<ImageInfo>> = [];
|
||||
|
||||
// 5a. Screenshots and heroArt metadata (top 4)
|
||||
for (const { url, buffer } of shots) {
|
||||
const rank = rankMap.get(url);
|
||||
if (rank === undefined || rank >= 4) continue;
|
||||
const type: ImageType = rank === 0 ? 'heroArt' : 'screenshot';
|
||||
tasks.push(
|
||||
Utils.getImageMetadata(buffer).then(meta => ({ ...meta, sourceUrl: url, position: type == "screenshot" ? rank - 1 : rank, type } as ImageInfo))
|
||||
);
|
||||
}
|
||||
|
||||
const settled = await Promise.allSettled(tasks);
|
||||
|
||||
settled
|
||||
.filter(r => r.status === "rejected")
|
||||
.forEach(r => console.warn("[getHeroArt] failed:", (r as PromiseRejectedResult).reason));
|
||||
|
||||
// Await all and return
|
||||
return settled.filter(s => s.status === "fulfilled").map(r => (r as PromiseFulfilledResult<ImageInfo>).value)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Verifies a Steam OpenID response by sending a request back to Steam
|
||||
* with mode=check_authentication
|
||||
*/
|
||||
export async function verifyOpenIDResponse(params: URLSearchParams): Promise<string | null> {
|
||||
try {
|
||||
// Create a new URLSearchParams with all the original parameters
|
||||
const verificationParams = new URLSearchParams();
|
||||
|
||||
// Copy all parameters from the original request
|
||||
for (const [key, value] of params.entries()) {
|
||||
verificationParams.append(key, value);
|
||||
}
|
||||
|
||||
// Change mode to check_authentication for verification
|
||||
verificationParams.set('openid.mode', 'check_authentication');
|
||||
|
||||
// Send verification request to Steam
|
||||
const verificationResponse = await fetch('https://steamcommunity.com/openid/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: verificationParams.toString()
|
||||
});
|
||||
|
||||
const responseText = await verificationResponse.text();
|
||||
|
||||
// Check if verification was successful
|
||||
if (!responseText.includes('is_valid:true')) {
|
||||
console.error('OpenID verification failed: Invalid response from Steam', responseText);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract steamID from the claimed_id
|
||||
const claimedId = params.get('openid.claimed_id');
|
||||
if (!claimedId) {
|
||||
console.error('OpenID verification failed: Missing claimed_id');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract the Steam ID from the claimed_id
|
||||
const steamID = claimedId.split('/').pop();
|
||||
if (!steamID || !/^\d+$/.test(steamID)) {
|
||||
console.error('OpenID verification failed: Invalid steamID format', steamID);
|
||||
return null;
|
||||
}
|
||||
|
||||
return steamID;
|
||||
} catch (error) {
|
||||
console.error('OpenID verification error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
544
packages/core/src/client/steam.ts
Normal file
544
packages/core/src/client/steam.ts
Normal file
@@ -0,0 +1,544 @@
|
||||
export namespace Steam {
|
||||
//Source: https://github.com/woctezuma/steam-api/blob/master/data/genres.json
|
||||
export const genres = {
|
||||
"1": "Action",
|
||||
"2": "Strategy",
|
||||
"3": "RPG",
|
||||
"4": "Casual",
|
||||
"9": "Racing",
|
||||
"18": "Sports",
|
||||
"23": "Indie",
|
||||
"25": "Adventure",
|
||||
"28": "Simulation",
|
||||
"29": "Massively Multiplayer",
|
||||
"37": "Free to Play",
|
||||
"50": "Accounting",
|
||||
"51": "Animation & Modeling",
|
||||
"52": "Audio Production",
|
||||
"53": "Design & Illustration",
|
||||
"54": "Education",
|
||||
"55": "Photo Editing",
|
||||
"56": "Software Training",
|
||||
"57": "Utilities",
|
||||
"58": "Video Production",
|
||||
"59": "Web Publishing",
|
||||
"60": "Game Development",
|
||||
"70": "Early Access",
|
||||
"71": "Sexual Content",
|
||||
"72": "Nudity",
|
||||
"73": "Violent",
|
||||
"74": "Gore",
|
||||
"80": "Movie",
|
||||
"81": "Documentary",
|
||||
"82": "Episodic",
|
||||
"83": "Short",
|
||||
"84": "Tutorial",
|
||||
"85": "360 Video"
|
||||
}
|
||||
|
||||
//Source: https://github.com/woctezuma/steam-api/blob/master/data/categories.json
|
||||
export const categories = {
|
||||
"1": "Multi-player",
|
||||
"2": "Single-player",
|
||||
"6": "Mods (require HL2)",
|
||||
"7": "Mods (require HL1)",
|
||||
"8": "Valve Anti-Cheat enabled",
|
||||
"9": "Co-op",
|
||||
"10": "Demos",
|
||||
"12": "HDR available",
|
||||
"13": "Captions available",
|
||||
"14": "Commentary available",
|
||||
"15": "Stats",
|
||||
"16": "Includes Source SDK",
|
||||
"17": "Includes level editor",
|
||||
"18": "Partial Controller Support",
|
||||
"19": "Mods",
|
||||
"20": "MMO",
|
||||
"21": "Downloadable Content",
|
||||
"22": "Steam Achievements",
|
||||
"23": "Steam Cloud",
|
||||
"24": "Shared/Split Screen",
|
||||
"25": "Steam Leaderboards",
|
||||
"27": "Cross-Platform Multiplayer",
|
||||
"28": "Full controller support",
|
||||
"29": "Steam Trading Cards",
|
||||
"30": "Steam Workshop",
|
||||
"31": "VR Support",
|
||||
"32": "Steam Turn Notifications",
|
||||
"33": "Native Steam Controller",
|
||||
"35": "In-App Purchases",
|
||||
"36": "Online PvP",
|
||||
"37": "Shared/Split Screen PvP",
|
||||
"38": "Online Co-op",
|
||||
"39": "Shared/Split Screen Co-op",
|
||||
"40": "SteamVR Collectibles",
|
||||
"41": "Remote Play on Phone",
|
||||
"42": "Remote Play on Tablet",
|
||||
"43": "Remote Play on TV",
|
||||
"44": "Remote Play Together",
|
||||
"45": "Cloud Gaming",
|
||||
"46": "Cloud Gaming (NVIDIA)",
|
||||
"47": "LAN PvP",
|
||||
"48": "LAN Co-op",
|
||||
"49": "PvP",
|
||||
"50": "Additional High-Quality Audio",
|
||||
"51": "Steam Workshop",
|
||||
"52": "Tracked Controller Support",
|
||||
"53": "VR Supported",
|
||||
"54": "VR Only"
|
||||
}
|
||||
|
||||
// Source: https://files.catbox.moe/96bty7.json
|
||||
export const tags = {
|
||||
"9": "Strategy",
|
||||
"19": "Action",
|
||||
"21": "Adventure",
|
||||
"84": "Design & Illustration",
|
||||
"87": "Utilities",
|
||||
"113": "Free to Play",
|
||||
"122": "RPG",
|
||||
"128": "Massively Multiplayer",
|
||||
"492": "Indie",
|
||||
"493": "Early Access",
|
||||
"597": "Casual",
|
||||
"599": "Simulation",
|
||||
"699": "Racing",
|
||||
"701": "Sports",
|
||||
"784": "Video Production",
|
||||
"809": "Photo Editing",
|
||||
"872": "Animation & Modeling",
|
||||
"1027": "Audio Production",
|
||||
"1036": "Education",
|
||||
"1038": "Web Publishing",
|
||||
"1445": "Software Training",
|
||||
"1616": "Trains",
|
||||
"1621": "Music",
|
||||
"1625": "Platformer",
|
||||
"1628": "Metroidvania",
|
||||
"1638": "Dog",
|
||||
"1643": "Building",
|
||||
"1644": "Driving",
|
||||
"1645": "Tower Defense",
|
||||
"1646": "Hack and Slash",
|
||||
"1647": "Western",
|
||||
"1649": "GameMaker",
|
||||
"1651": "Satire",
|
||||
"1654": "Relaxing",
|
||||
"1659": "Zombies",
|
||||
"1662": "Survival",
|
||||
"1663": "FPS",
|
||||
"1664": "Puzzle",
|
||||
"1665": "Match 3",
|
||||
"1666": "Card Game",
|
||||
"1667": "Horror",
|
||||
"1669": "Moddable",
|
||||
"1670": "4X",
|
||||
"1671": "Superhero",
|
||||
"1673": "Aliens",
|
||||
"1674": "Typing",
|
||||
"1676": "RTS",
|
||||
"1677": "Turn-Based",
|
||||
"1678": "War",
|
||||
"1680": "Heist",
|
||||
"1681": "Pirates",
|
||||
"1684": "Fantasy",
|
||||
"1685": "Co-op",
|
||||
"1687": "Stealth",
|
||||
"1688": "Ninja",
|
||||
"1693": "Classic",
|
||||
"1695": "Open World",
|
||||
"1697": "Third Person",
|
||||
"1698": "Point & Click",
|
||||
"1702": "Crafting",
|
||||
"1708": "Tactical",
|
||||
"1710": "Surreal",
|
||||
"1714": "Psychedelic",
|
||||
"1716": "Roguelike",
|
||||
"1717": "Hex Grid",
|
||||
"1718": "MOBA",
|
||||
"1719": "Comedy",
|
||||
"1720": "Dungeon Crawler",
|
||||
"1721": "Psychological Horror",
|
||||
"1723": "Action RTS",
|
||||
"1730": "Sokoban",
|
||||
"1732": "Voxel",
|
||||
"1733": "Unforgiving",
|
||||
"1734": "Fast-Paced",
|
||||
"1736": "LEGO",
|
||||
"1738": "Hidden Object",
|
||||
"1741": "Turn-Based Strategy",
|
||||
"1742": "Story Rich",
|
||||
"1743": "Fighting",
|
||||
"1746": "Basketball",
|
||||
"1751": "Comic Book",
|
||||
"1752": "Rhythm",
|
||||
"1753": "Skateboarding",
|
||||
"1754": "MMORPG",
|
||||
"1755": "Space",
|
||||
"1756": "Great Soundtrack",
|
||||
"1759": "Perma Death",
|
||||
"1770": "Board Game",
|
||||
"1773": "Arcade",
|
||||
"1774": "Shooter",
|
||||
"1775": "PvP",
|
||||
"1777": "Steampunk",
|
||||
"3796": "Based On A Novel",
|
||||
"3798": "Side Scroller",
|
||||
"3799": "Visual Novel",
|
||||
"3810": "Sandbox",
|
||||
"3813": "Real Time Tactics",
|
||||
"3814": "Third-Person Shooter",
|
||||
"3834": "Exploration",
|
||||
"3835": "Post-apocalyptic",
|
||||
"3839": "First-Person",
|
||||
"3841": "Local Co-Op",
|
||||
"3843": "Online Co-Op",
|
||||
"3854": "Lore-Rich",
|
||||
"3859": "Multiplayer",
|
||||
"3871": "2D",
|
||||
"3877": "Precision Platformer",
|
||||
"3878": "Competitive",
|
||||
"3916": "Old School",
|
||||
"3920": "Cooking",
|
||||
"3934": "Immersive",
|
||||
"3942": "Sci-fi",
|
||||
"3952": "Gothic",
|
||||
"3955": "Character Action Game",
|
||||
"3959": "Roguelite",
|
||||
"3964": "Pixel Graphics",
|
||||
"3965": "Epic",
|
||||
"3968": "Physics",
|
||||
"3978": "Survival Horror",
|
||||
"3987": "Historical",
|
||||
"3993": "Combat",
|
||||
"4004": "Retro",
|
||||
"4018": "Vampire",
|
||||
"4026": "Difficult",
|
||||
"4036": "Parkour",
|
||||
"4046": "Dragons",
|
||||
"4057": "Magic",
|
||||
"4064": "Thriller",
|
||||
"4085": "Anime",
|
||||
"4094": "Minimalist",
|
||||
"4102": "Combat Racing",
|
||||
"4106": "Action-Adventure",
|
||||
"4115": "Cyberpunk",
|
||||
"4136": "Funny",
|
||||
"4137": "Transhumanism",
|
||||
"4145": "Cinematic",
|
||||
"4150": "World War II",
|
||||
"4155": "Class-Based",
|
||||
"4158": "Beat 'em up",
|
||||
"4161": "Real-Time",
|
||||
"4166": "Atmospheric",
|
||||
"4168": "Military",
|
||||
"4172": "Medieval",
|
||||
"4175": "Realistic",
|
||||
"4182": "Singleplayer",
|
||||
"4184": "Chess",
|
||||
"4190": "Addictive",
|
||||
"4191": "3D",
|
||||
"4195": "Cartoony",
|
||||
"4202": "Trading",
|
||||
"4231": "Action RPG",
|
||||
"4234": "Short",
|
||||
"4236": "Loot",
|
||||
"4242": "Episodic",
|
||||
"4252": "Stylized",
|
||||
"4255": "Shoot 'Em Up",
|
||||
"4291": "Spaceships",
|
||||
"4295": "Futuristic",
|
||||
"4305": "Colorful",
|
||||
"4325": "Turn-Based Combat",
|
||||
"4328": "City Builder",
|
||||
"4342": "Dark",
|
||||
"4345": "Gore",
|
||||
"4364": "Grand Strategy",
|
||||
"4376": "Assassin",
|
||||
"4400": "Abstract",
|
||||
"4434": "JRPG",
|
||||
"4474": "CRPG",
|
||||
"4486": "Choose Your Own Adventure",
|
||||
"4508": "Co-op Campaign",
|
||||
"4520": "Farming",
|
||||
"4559": "Quick-Time Events",
|
||||
"4562": "Cartoon",
|
||||
"4598": "Alternate History",
|
||||
"4604": "Dark Fantasy",
|
||||
"4608": "Swordplay",
|
||||
"4637": "Top-Down Shooter",
|
||||
"4667": "Violent",
|
||||
"4684": "Wargame",
|
||||
"4695": "Economy",
|
||||
"4700": "Movie",
|
||||
"4711": "Replay Value",
|
||||
"4726": "Cute",
|
||||
"4736": "2D Fighter",
|
||||
"4747": "Character Customization",
|
||||
"4754": "Politics",
|
||||
"4758": "Twin Stick Shooter",
|
||||
"4777": "Spectacle fighter",
|
||||
"4791": "Top-Down",
|
||||
"4821": "Mechs",
|
||||
"4835": "6DOF",
|
||||
"4840": "4 Player Local",
|
||||
"4845": "Capitalism",
|
||||
"4853": "Political",
|
||||
"4878": "Parody",
|
||||
"4885": "Bullet Hell",
|
||||
"4947": "Romance",
|
||||
"4975": "2.5D",
|
||||
"4994": "Naval Combat",
|
||||
"5030": "Dystopian",
|
||||
"5055": "eSports",
|
||||
"5094": "Narration",
|
||||
"5125": "Procedural Generation",
|
||||
"5153": "Kickstarter",
|
||||
"5154": "Score Attack",
|
||||
"5160": "Dinosaurs",
|
||||
"5179": "Cold War",
|
||||
"5186": "Psychological",
|
||||
"5228": "Blood",
|
||||
"5230": "Sequel",
|
||||
"5300": "God Game",
|
||||
"5310": "Games Workshop",
|
||||
"5348": "Mod",
|
||||
"5350": "Family Friendly",
|
||||
"5363": "Destruction",
|
||||
"5372": "Conspiracy",
|
||||
"5379": "2D Platformer",
|
||||
"5382": "World War I",
|
||||
"5390": "Time Attack",
|
||||
"5395": "3D Platformer",
|
||||
"5407": "Benchmark",
|
||||
"5411": "Beautiful",
|
||||
"5432": "Programming",
|
||||
"5502": "Hacking",
|
||||
"5537": "Puzzle Platformer",
|
||||
"5547": "Arena Shooter",
|
||||
"5577": "RPGMaker",
|
||||
"5608": "Emotional",
|
||||
"5611": "Mature",
|
||||
"5613": "Detective",
|
||||
"5652": "Collectathon",
|
||||
"5673": "Modern",
|
||||
"5708": "Remake",
|
||||
"5711": "Team-Based",
|
||||
"5716": "Mystery",
|
||||
"5727": "Baseball",
|
||||
"5752": "Robots",
|
||||
"5765": "Gun Customization",
|
||||
"5794": "Science",
|
||||
"5796": "Bullet Time",
|
||||
"5851": "Isometric",
|
||||
"5900": "Walking Simulator",
|
||||
"5914": "Tennis",
|
||||
"5923": "Dark Humor",
|
||||
"5941": "Reboot",
|
||||
"5981": "Mining",
|
||||
"5984": "Drama",
|
||||
"6041": "Horses",
|
||||
"6052": "Noir",
|
||||
"6129": "Logic",
|
||||
"6214": "Birds",
|
||||
"6276": "Inventory Management",
|
||||
"6310": "Diplomacy",
|
||||
"6378": "Crime",
|
||||
"6426": "Choices Matter",
|
||||
"6506": "3D Fighter",
|
||||
"6621": "Pinball",
|
||||
"6625": "Time Manipulation",
|
||||
"6650": "Nudity",
|
||||
"6691": "1990's",
|
||||
"6702": "Mars",
|
||||
"6730": "PvE",
|
||||
"6815": "Hand-drawn",
|
||||
"6869": "Nonlinear",
|
||||
"6910": "Naval",
|
||||
"6915": "Martial Arts",
|
||||
"6948": "Rome",
|
||||
"6971": "Multiple Endings",
|
||||
"7038": "Golf",
|
||||
"7107": "Real-Time with Pause",
|
||||
"7108": "Party",
|
||||
"7113": "Crowdfunded",
|
||||
"7178": "Party Game",
|
||||
"7208": "Female Protagonist",
|
||||
"7250": "Linear",
|
||||
"7309": "Skiing",
|
||||
"7328": "Bowling",
|
||||
"7332": "Base Building",
|
||||
"7368": "Local Multiplayer",
|
||||
"7423": "Sniper",
|
||||
"7432": "Lovecraftian",
|
||||
"7478": "Illuminati",
|
||||
"7481": "Controller",
|
||||
"7556": "Dice",
|
||||
"7569": "Grid-Based Movement",
|
||||
"7622": "Offroad",
|
||||
"7702": "Narrative",
|
||||
"7743": "1980s",
|
||||
"7782": "Cult Classic",
|
||||
"7918": "Dwarf",
|
||||
"7926": "Artificial Intelligence",
|
||||
"7948": "Soundtrack",
|
||||
"8013": "Software",
|
||||
"8075": "TrackIR",
|
||||
"8093": "Minigames",
|
||||
"8122": "Level Editor",
|
||||
"8253": "Music-Based Procedural Generation",
|
||||
"8369": "Investigation",
|
||||
"8461": "Well-Written",
|
||||
"8666": "Runner",
|
||||
"8945": "Resource Management",
|
||||
"9130": "Hentai",
|
||||
"9157": "Underwater",
|
||||
"9204": "Immersive Sim",
|
||||
"9271": "Trading Card Game",
|
||||
"9541": "Demons",
|
||||
"9551": "Dating Sim",
|
||||
"9564": "Hunting",
|
||||
"9592": "Dynamic Narration",
|
||||
"9803": "Snow",
|
||||
"9994": "Experience",
|
||||
"10235": "Life Sim",
|
||||
"10383": "Transportation",
|
||||
"10397": "Memes",
|
||||
"10437": "Trivia",
|
||||
"10679": "Time Travel",
|
||||
"10695": "Party-Based RPG",
|
||||
"10808": "Supernatural",
|
||||
"10816": "Split Screen",
|
||||
"11014": "Interactive Fiction",
|
||||
"11095": "Boss Rush",
|
||||
"11104": "Vehicular Combat",
|
||||
"11123": "Mouse only",
|
||||
"11333": "Villain Protagonist",
|
||||
"11634": "Vikings",
|
||||
"12057": "Tutorial",
|
||||
"12095": "Sexual Content",
|
||||
"12190": "Boxing",
|
||||
"12286": "Warhammer 40K",
|
||||
"12472": "Management",
|
||||
"13070": "Solitaire",
|
||||
"13190": "America",
|
||||
"13276": "Tanks",
|
||||
"13382": "Archery",
|
||||
"13577": "Sailing",
|
||||
"13782": "Experimental",
|
||||
"13906": "Game Development",
|
||||
"14139": "Turn-Based Tactics",
|
||||
"14153": "Dungeons & Dragons",
|
||||
"14720": "Nostalgia",
|
||||
"14906": "Intentionally Awkward Controls",
|
||||
"15045": "Flight",
|
||||
"15172": "Conversation",
|
||||
"15277": "Philosophical",
|
||||
"15339": "Documentary",
|
||||
"15564": "Fishing",
|
||||
"15868": "Motocross",
|
||||
"15954": "Silent Protagonist",
|
||||
"16094": "Mythology",
|
||||
"16250": "Gambling",
|
||||
"16598": "Space Sim",
|
||||
"16689": "Time Management",
|
||||
"17015": "Werewolves",
|
||||
"17305": "Strategy RPG",
|
||||
"17337": "Lemmings",
|
||||
"17389": "Tabletop",
|
||||
"17770": "Asynchronous Multiplayer",
|
||||
"17894": "Cats",
|
||||
"17927": "Pool",
|
||||
"18594": "FMV",
|
||||
"19568": "Cycling",
|
||||
"19780": "Submarine",
|
||||
"19995": "Dark Comedy",
|
||||
"21006": "Underground",
|
||||
"21491": "Demo Available",
|
||||
"21725": "Tactical RPG",
|
||||
"21978": "VR",
|
||||
"22602": "Agriculture",
|
||||
"22955": "Mini Golf",
|
||||
"24003": "Word Game",
|
||||
"24904": "NSFW",
|
||||
"25085": "Touch-Friendly",
|
||||
"26921": "Political Sim",
|
||||
"27758": "Voice Control",
|
||||
"28444": "Snowboarding",
|
||||
"29363": "3D Vision",
|
||||
"29482": "Souls-like",
|
||||
"29855": "Ambient",
|
||||
"30358": "Nature",
|
||||
"30927": "Fox",
|
||||
"31275": "Text-Based",
|
||||
"31579": "Otome",
|
||||
"32322": "Deckbuilding",
|
||||
"33572": "Mahjong",
|
||||
"35079": "Job Simulator",
|
||||
"42089": "Jump Scare",
|
||||
"42329": "Coding",
|
||||
"42804": "Action Roguelike",
|
||||
"44868": "LGBTQ+",
|
||||
"47827": "Wrestling",
|
||||
"49213": "Rugby",
|
||||
"51306": "Foreign",
|
||||
"56690": "On-Rails Shooter",
|
||||
"61357": "Electronic Music",
|
||||
"65443": "Adult Content",
|
||||
"71389": "Spelling",
|
||||
"87918": "Farming Sim",
|
||||
"91114": "Shop Keeper",
|
||||
"92092": "Jet",
|
||||
"96359": "Skating",
|
||||
"97376": "Cozy",
|
||||
"102530": "Elf",
|
||||
"117648": "8-bit Music",
|
||||
"123332": "Bikes",
|
||||
"129761": "ATV",
|
||||
"143739": "Electronic",
|
||||
"150626": "Gaming",
|
||||
"158638": "Cricket",
|
||||
"176981": "Battle Royale",
|
||||
"180368": "Faith",
|
||||
"189941": "Instrumental Music",
|
||||
"198631": "Mystery Dungeon",
|
||||
"198913": "Motorbike",
|
||||
"220585": "Colony Sim",
|
||||
"233824": "Feature Film",
|
||||
"252854": "BMX",
|
||||
"255534": "Automation",
|
||||
"323922": "Musou",
|
||||
"324176": "Hockey",
|
||||
"337964": "Rock Music",
|
||||
"348922": "Steam Machine",
|
||||
"353880": "Looter Shooter",
|
||||
"363767": "Snooker",
|
||||
"379975": "Clicker",
|
||||
"454187": "Traditional Roguelike",
|
||||
"552282": "Wholesome",
|
||||
"603297": "Hardware",
|
||||
"615955": "Idler",
|
||||
"620519": "Hero Shooter",
|
||||
"745697": "Social Deduction",
|
||||
"769306": "Escape Room",
|
||||
"776177": "360 Video",
|
||||
"791774": "Card Battler",
|
||||
"847164": "Volleyball",
|
||||
"856791": "Asymmetric VR",
|
||||
"916648": "Creature Collector",
|
||||
"922563": "Roguevania",
|
||||
"1003823": "Profile Features Limited",
|
||||
"1023537": "Boomer Shooter",
|
||||
"1084988": "Auto Battler",
|
||||
"1091588": "Roguelike Deckbuilder",
|
||||
"1100686": "Outbreak Sim",
|
||||
"1100687": "Automobile Sim",
|
||||
"1100688": "Medical Sim",
|
||||
"1100689": "Open World Survival Craft",
|
||||
"1199779": "Extraction Shooter",
|
||||
"1220528": "Hobby Sim",
|
||||
"1254546": "Football (Soccer)",
|
||||
"1254552": "Football (American)",
|
||||
"1368160": "AI Content Disclosed",
|
||||
}
|
||||
}
|
||||
600
packages/core/src/client/types.ts
Normal file
600
packages/core/src/client/types.ts
Normal file
@@ -0,0 +1,600 @@
|
||||
export interface SteamApp {
|
||||
/** Steam application ID */
|
||||
appid: number;
|
||||
|
||||
/** Array of Steam IDs that own this app */
|
||||
owner_steamids: string[];
|
||||
|
||||
/** Name of the game/application */
|
||||
name: string;
|
||||
|
||||
/** Filename of the game's capsule image */
|
||||
capsule_filename: string;
|
||||
|
||||
/** Hash value for the game's icon */
|
||||
img_icon_hash: string;
|
||||
|
||||
/** Reason code for exclusion (0 indicates no exclusion) */
|
||||
exclude_reason: number;
|
||||
|
||||
/** Unix timestamp when the app was acquired */
|
||||
rt_time_acquired: number;
|
||||
|
||||
/** Unix timestamp when the app was last played */
|
||||
rt_last_played: number;
|
||||
|
||||
/** Total playtime in seconds */
|
||||
rt_playtime: number;
|
||||
|
||||
/** Type identifier for the app (1 = game) */
|
||||
app_type: number;
|
||||
|
||||
/** Array of content descriptor IDs */
|
||||
content_descriptors?: number[];
|
||||
}
|
||||
|
||||
export interface SteamApiResponse {
|
||||
response: {
|
||||
apps: SteamApp[];
|
||||
owner_steamid: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SteamAppDataResponse {
|
||||
data: Record<string, SteamAppEntry>;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface SteamAppEntry {
|
||||
_change_number: number;
|
||||
_missing_token: boolean;
|
||||
_sha: string;
|
||||
_size: number;
|
||||
appid: string;
|
||||
common: CommonData;
|
||||
config: AppConfig;
|
||||
depots: AppDepots;
|
||||
extended: AppExtended;
|
||||
ufs: UFSData;
|
||||
}
|
||||
|
||||
export interface CommonData {
|
||||
associations: Record<string, { name: string; type: string }>;
|
||||
category: Record<string, string>;
|
||||
clienticon: string;
|
||||
clienttga: string;
|
||||
community_hub_visible: string;
|
||||
community_visible_stats: string;
|
||||
content_descriptors: Record<string, string>;
|
||||
controller_support?: string;
|
||||
controllertagwizard: string;
|
||||
gameid: string;
|
||||
genres: Record<string, string>;
|
||||
header_image: Record<string, string>;
|
||||
icon: string;
|
||||
languages: Record<string, string>;
|
||||
library_assets: LibraryAssets;
|
||||
library_assets_full: LibraryAssetsFull;
|
||||
metacritic_fullurl: string;
|
||||
metacritic_name: string;
|
||||
metacritic_score: string;
|
||||
name: string;
|
||||
name_localized: Partial<Record<LanguageCode, string>>;
|
||||
osarch: string;
|
||||
osextended: string;
|
||||
oslist: string;
|
||||
primary_genre: string;
|
||||
releasestate: string;
|
||||
review_percentage: string;
|
||||
review_score: string;
|
||||
small_capsule: Record<string, string>;
|
||||
steam_deck_compatibility: SteamDeckCompatibility;
|
||||
steam_release_date: string;
|
||||
store_asset_mtime: string;
|
||||
store_tags: Record<string, string>;
|
||||
supported_languages: Record<
|
||||
string,
|
||||
{
|
||||
full_audio?: string;
|
||||
subtitles?: string;
|
||||
supported?: string;
|
||||
}
|
||||
>;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface LibraryAssets {
|
||||
library_capsule: string;
|
||||
library_header: string;
|
||||
library_hero: string;
|
||||
library_logo: string;
|
||||
logo_position: LogoPosition;
|
||||
}
|
||||
|
||||
export interface LogoPosition {
|
||||
height_pct: string;
|
||||
pinned_position: string;
|
||||
width_pct: string;
|
||||
}
|
||||
|
||||
export interface LibraryAssetsFull {
|
||||
library_capsule: ImageSet;
|
||||
library_header: ImageSet;
|
||||
library_hero: ImageSet;
|
||||
library_logo: ImageSet & { logo_position: LogoPosition };
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface ImageSet {
|
||||
image: Record<string, string>;
|
||||
image2x?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface SteamDeckCompatibility {
|
||||
category: string;
|
||||
configuration: Record<string, string>;
|
||||
test_timestamp: string;
|
||||
tested_build_id: string;
|
||||
tests: Record<string, { display: string; token: string }>;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
installdir: string;
|
||||
launch: Record<
|
||||
string,
|
||||
{
|
||||
executable: string;
|
||||
type: string;
|
||||
arguments?: string;
|
||||
description?: string;
|
||||
description_loc?: Record<string, string>;
|
||||
config?: {
|
||||
betakey: string;
|
||||
};
|
||||
}
|
||||
>;
|
||||
steamcontrollertemplateindex: string;
|
||||
steamdecktouchscreen: string;
|
||||
}
|
||||
|
||||
export interface AppDepots {
|
||||
branches: AppDepotBranches;
|
||||
privatebranches: Record<string, AppDepotBranches>;
|
||||
[depotId: string]: DepotEntry
|
||||
| AppDepotBranches
|
||||
| Record<string, AppDepotBranches>;
|
||||
}
|
||||
|
||||
|
||||
export interface DepotEntry {
|
||||
manifests: {
|
||||
public: {
|
||||
download: string;
|
||||
gid: string;
|
||||
size: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface AppDepotBranches {
|
||||
[branchName: string]: {
|
||||
buildid: string;
|
||||
timeupdated: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AppExtended {
|
||||
additional_dependencies: Array<{
|
||||
dest_os: string;
|
||||
h264: string;
|
||||
src_os: string;
|
||||
}>;
|
||||
developer: string;
|
||||
dlcavailableonstore: string;
|
||||
homepage: string;
|
||||
listofdlc: string;
|
||||
publisher: string;
|
||||
}
|
||||
|
||||
export interface UFSData {
|
||||
maxnumfiles: string;
|
||||
quota: string;
|
||||
savefiles: Array<{
|
||||
path: string;
|
||||
pattern: string;
|
||||
recursive: string;
|
||||
root: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export type LanguageCode =
|
||||
| "english"
|
||||
| "french"
|
||||
| "german"
|
||||
| "italian"
|
||||
| "japanese"
|
||||
| "koreana"
|
||||
| "polish"
|
||||
| "russian"
|
||||
| "schinese"
|
||||
| "tchinese"
|
||||
| "brazilian"
|
||||
| "spanish";
|
||||
|
||||
export interface Screenshot {
|
||||
appid: number;
|
||||
id: number;
|
||||
filename: string;
|
||||
all_ages: string;
|
||||
normalized_name: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
strDisplayName: string;
|
||||
}
|
||||
|
||||
export interface ReviewSummary {
|
||||
strReviewSummary: string;
|
||||
cReviews: number;
|
||||
cRecommendationsPositive: number;
|
||||
cRecommendationsNegative: number;
|
||||
nReviewScore: number;
|
||||
}
|
||||
|
||||
export interface GameDetailsResponse {
|
||||
strReleaseDate: string;
|
||||
strDescription: string;
|
||||
rgScreenshots: Screenshot[];
|
||||
rgCategories: Category[];
|
||||
strGenres?: string;
|
||||
strFullDescription: string;
|
||||
strMicroTrailerURL: string;
|
||||
ReviewSummary: ReviewSummary;
|
||||
}
|
||||
|
||||
// Define the TypeScript interfaces
|
||||
export interface Tag {
|
||||
tagid: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TagWithSlug {
|
||||
name: string;
|
||||
slug: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface StoreTags {
|
||||
[key: string]: string; // Index signature for numeric string keys to tag ID strings
|
||||
}
|
||||
|
||||
|
||||
export interface GameTagsResponse {
|
||||
tags: Tag[];
|
||||
success: number;
|
||||
rwgrsn: number;
|
||||
}
|
||||
|
||||
export type GenreType = {
|
||||
type: 'genre';
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export interface AppInfo {
|
||||
name: string;
|
||||
slug: string;
|
||||
images: {
|
||||
logo: string;
|
||||
backdrop: string;
|
||||
poster: string;
|
||||
banner: string;
|
||||
screenshots: string[];
|
||||
icon: string;
|
||||
}
|
||||
links: string[] | null;
|
||||
score: number;
|
||||
id: string;
|
||||
releaseDate: Date;
|
||||
description: string | null;
|
||||
compatibility: "low" | "mid" | "high" | "unknown";
|
||||
controllerSupport: "partial" | "full" | "unknown";
|
||||
primaryGenre: string | null;
|
||||
size: { downloadSize: number; sizeOnDisk: number };
|
||||
tags: Array<{ name: string; slug: string; type: "tag" }>;
|
||||
genres: Array<{ type: "genre"; name: string; slug: string }>;
|
||||
categories: Array<{ name: string; slug: string; type: "categorie" }>;
|
||||
franchises: Array<{ name: string; slug: string; type: "franchise" }>;
|
||||
developers: Array<{ name: string; slug: string; type: "developer" }>;
|
||||
publishers: Array<{ name: string; slug: string; type: "publisher" }>;
|
||||
}
|
||||
|
||||
export type ImageType =
|
||||
| 'screenshot'
|
||||
| 'boxArt'
|
||||
| 'banner'
|
||||
| 'backdrop'
|
||||
| 'icon'
|
||||
| 'logo'
|
||||
| 'poster'
|
||||
| 'heroArt';
|
||||
|
||||
export interface ImageInfo {
|
||||
type: ImageType;
|
||||
position: number;
|
||||
hash: string;
|
||||
sourceUrl: string | null;
|
||||
format?: string;
|
||||
averageColor: { hex: string; isDark: boolean };
|
||||
dimensions: { width: number; height: number };
|
||||
fileSize: number;
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
export interface CompareOpts {
|
||||
/** Pixelmatch color threshold (0–1). Default: 0.1 */
|
||||
threshold?: number;
|
||||
/** If true, return an image buffer of the diff map. Default: false */
|
||||
diffOutput?: boolean;
|
||||
}
|
||||
|
||||
export interface CompareResult {
|
||||
diffRatio: number;
|
||||
/** Present only if `diffOutput: true` */
|
||||
diffBuffer?: Buffer;
|
||||
}
|
||||
|
||||
export interface Shot {
|
||||
url: string;
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
export interface RankedShot {
|
||||
url: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface SteamPlayerSummaryResponse {
|
||||
response: {
|
||||
players: SteamPlayerSummary[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SteamPlayerSummary {
|
||||
steamid: string;
|
||||
communityvisibilitystate: number;
|
||||
profilestate?: number;
|
||||
personaname: string;
|
||||
profileurl: string;
|
||||
avatar: string;
|
||||
avatarmedium: string;
|
||||
avatarfull: string;
|
||||
avatarhash: string;
|
||||
lastlogoff?: number;
|
||||
personastate: number;
|
||||
realname?: string;
|
||||
primaryclanid?: string;
|
||||
timecreated: number;
|
||||
personastateflags?: number;
|
||||
loccountrycode?: string;
|
||||
}
|
||||
|
||||
export interface SteamPlayerBansResponse {
|
||||
players: SteamPlayerBan[];
|
||||
}
|
||||
|
||||
export interface SteamPlayerBan {
|
||||
SteamId: string;
|
||||
CommunityBanned: boolean;
|
||||
VACBanned: boolean;
|
||||
NumberOfVACBans: number;
|
||||
DaysSinceLastBan: number;
|
||||
NumberOfGameBans: number;
|
||||
EconomyBan: 'none' | 'probation' | 'banned'; // Enum based on known possible values
|
||||
}
|
||||
|
||||
export type SteamAccount = {
|
||||
id: string;
|
||||
name: string;
|
||||
realName: string | null;
|
||||
steamMemberSince: Date;
|
||||
avatarHash: string;
|
||||
limitations: {
|
||||
isLimited: boolean;
|
||||
tradeBanState: 'none' | 'probation' | 'banned';
|
||||
isVacBanned: boolean;
|
||||
visibilityState: number;
|
||||
privacyState: 'public' | 'private' | 'friendsonly';
|
||||
};
|
||||
profileUrl: string;
|
||||
lastSyncedAt: Date;
|
||||
};
|
||||
|
||||
export interface SteamFriendsListResponse {
|
||||
friendslist: {
|
||||
friends: SteamFriend[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SteamFriend {
|
||||
steamid: string;
|
||||
relationship: 'friend'; // could expand this if Steam ever adds more types
|
||||
friend_since: number; // Unix timestamp (seconds)
|
||||
}
|
||||
|
||||
export interface SteamOwnedGamesResponse {
|
||||
response: {
|
||||
game_count: number;
|
||||
games: SteamOwnedGame[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SteamOwnedGame {
|
||||
appid: number;
|
||||
name: string;
|
||||
playtime_forever: number;
|
||||
img_icon_url: string;
|
||||
|
||||
playtime_windows_forever?: number;
|
||||
playtime_mac_forever?: number;
|
||||
playtime_linux_forever?: number;
|
||||
playtime_deck_forever?: number;
|
||||
|
||||
rtime_last_played?: number; // Unix timestamp
|
||||
content_descriptorids?: number[];
|
||||
playtime_disconnected?: number;
|
||||
has_community_visible_stats?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape of the parsed Steam profile information.
|
||||
*/
|
||||
export interface ProfileInfo {
|
||||
steamID64: string;
|
||||
isLimited: boolean;
|
||||
privacyState: 'public' | 'private' | 'friendsonly' | string;
|
||||
visibility: string;
|
||||
}
|
||||
|
||||
export interface SteamStoreResponse {
|
||||
response: {
|
||||
store_items: SteamStoreItem[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SteamStoreItem {
|
||||
item_type: number;
|
||||
id: number;
|
||||
success: number;
|
||||
visible: boolean;
|
||||
name: string;
|
||||
store_url_path: string;
|
||||
appid: number;
|
||||
type: number;
|
||||
tagids: number[];
|
||||
categories: {
|
||||
supported_player_categoryids?: number[];
|
||||
feature_categoryids?: number[];
|
||||
controller_categoryids?: number[];
|
||||
};
|
||||
reviews: {
|
||||
summary_filtered: {
|
||||
review_count: number;
|
||||
percent_positive: number;
|
||||
review_score: number;
|
||||
review_score_label: string;
|
||||
};
|
||||
};
|
||||
basic_info: {
|
||||
short_description?: string;
|
||||
publishers: SteamCreator[];
|
||||
developers: SteamCreator[];
|
||||
franchises?: SteamCreator[];
|
||||
};
|
||||
tags: {
|
||||
tagid: number;
|
||||
weight: number;
|
||||
}[];
|
||||
assets: SteamAssets;
|
||||
assets_without_overrides: SteamAssets;
|
||||
release: {
|
||||
steam_release_date: number;
|
||||
};
|
||||
platforms: {
|
||||
windows: boolean;
|
||||
mac: boolean;
|
||||
steamos_linux: boolean;
|
||||
vr_support: Record<string, never>;
|
||||
steam_deck_compat_category?: number;
|
||||
steam_os_compat_category?: number;
|
||||
};
|
||||
best_purchase_option: PurchaseOption;
|
||||
purchase_options: PurchaseOption[];
|
||||
screenshots: {
|
||||
all_ages_screenshots: {
|
||||
filename: string;
|
||||
ordinal: number;
|
||||
}[];
|
||||
};
|
||||
trailers: {
|
||||
highlights: Trailer[];
|
||||
};
|
||||
supported_languages: SupportedLanguage[];
|
||||
full_description: string;
|
||||
links?: {
|
||||
link_type: number;
|
||||
url: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface SteamCreator {
|
||||
name: string;
|
||||
creator_clan_account_id: number;
|
||||
}
|
||||
|
||||
export interface SteamAssets {
|
||||
asset_url_format: string;
|
||||
main_capsule: string;
|
||||
small_capsule: string;
|
||||
header: string;
|
||||
page_background: string;
|
||||
hero_capsule: string;
|
||||
hero_capsule_2x: string;
|
||||
library_capsule: string;
|
||||
library_capsule_2x: string;
|
||||
library_hero: string;
|
||||
library_hero_2x: string;
|
||||
community_icon: string;
|
||||
page_background_path: string;
|
||||
raw_page_background: string;
|
||||
}
|
||||
|
||||
export interface PurchaseOption {
|
||||
packageid?: number;
|
||||
bundleid?: number;
|
||||
purchase_option_name: string;
|
||||
final_price_in_cents: string;
|
||||
original_price_in_cents: string;
|
||||
formatted_final_price: string;
|
||||
formatted_original_price: string;
|
||||
discount_pct: number;
|
||||
active_discounts: ActiveDiscount[];
|
||||
user_can_purchase_as_gift: boolean;
|
||||
hide_discount_pct_for_compliance: boolean;
|
||||
included_game_count: number;
|
||||
bundle_discount_pct?: number;
|
||||
price_before_bundle_discount?: string;
|
||||
formatted_price_before_bundle_discount?: string;
|
||||
}
|
||||
|
||||
export interface ActiveDiscount {
|
||||
discount_amount: string;
|
||||
discount_description: string;
|
||||
discount_end_date: number;
|
||||
}
|
||||
|
||||
export interface Trailer {
|
||||
trailer_name: string;
|
||||
trailer_url_format: string;
|
||||
trailer_category: number;
|
||||
trailer_480p: TrailerFile[];
|
||||
trailer_max: TrailerFile[];
|
||||
microtrailer: TrailerFile[];
|
||||
screenshot_medium: string;
|
||||
screenshot_full: string;
|
||||
trailer_base_id: number;
|
||||
all_ages: boolean;
|
||||
}
|
||||
|
||||
export interface TrailerFile {
|
||||
filename: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface SupportedLanguage {
|
||||
elanguage: number;
|
||||
eadditionallanguage: number;
|
||||
supported: boolean;
|
||||
full_audio: boolean;
|
||||
subtitles: boolean;
|
||||
}
|
||||
524
packages/core/src/client/utils.ts
Normal file
524
packages/core/src/client/utils.ts
Normal file
@@ -0,0 +1,524 @@
|
||||
import type {
|
||||
Tag,
|
||||
StoreTags,
|
||||
AppDepots,
|
||||
GenreType,
|
||||
LibraryAssetsFull,
|
||||
DepotEntry,
|
||||
CompareOpts,
|
||||
CompareResult,
|
||||
RankedShot,
|
||||
Shot,
|
||||
ProfileInfo,
|
||||
} from "./types";
|
||||
import crypto from 'crypto';
|
||||
import pLimit from 'p-limit';
|
||||
import { PNG } from 'pngjs';
|
||||
import pixelmatch from 'pixelmatch';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import { Agent as HttpAgent } from 'http';
|
||||
import { Agent as HttpsAgent } from 'https';
|
||||
import { parseStringPromise } from "xml2js";
|
||||
import sharp, { type Metadata } from 'sharp';
|
||||
import AbortController from 'abort-controller';
|
||||
import fetch, { RequestInit } from 'node-fetch';
|
||||
import { FastAverageColor } from 'fast-average-color';
|
||||
|
||||
const fac = new FastAverageColor()
|
||||
// --- Configuration ---
|
||||
const httpAgent = new HttpAgent({ keepAlive: true, maxSockets: 50 });
|
||||
const httpsAgent = new HttpsAgent({ keepAlive: true, maxSockets: 50 });
|
||||
const downloadCache = new LRUCache<string, Buffer>({
|
||||
max: 100,
|
||||
ttl: 1000 * 60 * 30, // 30-minute expiry
|
||||
allowStale: false,
|
||||
});
|
||||
const downloadLimit = pLimit(10); // max concurrent downloads
|
||||
const compareCache = new LRUCache<string, CompareResult>({
|
||||
max: 50,
|
||||
ttl: 1000 * 60 * 10, // 10-minute expiry
|
||||
});
|
||||
|
||||
export namespace Utils {
|
||||
export async function fetchBuffer(url: string, retries = 3): Promise<Buffer> {
|
||||
if (downloadCache.has(url)) {
|
||||
return downloadCache.get(url)!;
|
||||
}
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < retries; attempt++) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const id = setTimeout(() => controller.abort(), 15_000);
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
agent: (_parsed) => _parsed.protocol === 'http:' ? httpAgent : httpsAgent
|
||||
} as RequestInit);
|
||||
clearTimeout(id);
|
||||
if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
downloadCache.set(url, buf);
|
||||
return buf;
|
||||
} catch (error: any) {
|
||||
lastError = error as Error;
|
||||
console.warn(`Attempt ${attempt + 1} failed for ${url}: ${error.message}`);
|
||||
if (attempt < retries - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error(`Failed to fetch ${url} after ${retries} attempts`);
|
||||
}
|
||||
|
||||
export async function getImageMetadata(buffer: Buffer) {
|
||||
const hash = crypto.createHash('sha256').update(buffer).digest('hex');
|
||||
const { width, height, format, size: fileSize } = await sharp(buffer).metadata();
|
||||
if (!width || !height) throw new Error('Invalid dimensions');
|
||||
|
||||
const slice = await sharp(buffer)
|
||||
.resize({ width: Math.min(width, 256) }) // cheap shrink
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
|
||||
const pixelArray = new Uint8Array(slice.buffer);
|
||||
const { hex, isDark } = fac.prepareResult(fac.getColorFromArray4(pixelArray, { mode: "precision" }));
|
||||
|
||||
return { hash, format, averageColor: { hex, isDark }, dimensions: { width, height }, fileSize, buffer };
|
||||
}
|
||||
|
||||
// --- Optimized Box Art creation ---
|
||||
export async function createBoxArtBuffer(
|
||||
logoUrl: string,
|
||||
backgroundUrl: string,
|
||||
logoPercent = 0.9
|
||||
): Promise<Buffer> {
|
||||
const [bgBuf, logoBuf] = await Promise.all([
|
||||
downloadLimit(() =>
|
||||
fetchBuffer(backgroundUrl)
|
||||
.catch(error => {
|
||||
console.error(`Failed to download hero image from ${backgroundUrl}:`, error);
|
||||
throw new Error(`Failed to create box art: hero image unavailable`);
|
||||
}),
|
||||
),
|
||||
downloadLimit(() => fetchBuffer(logoUrl)
|
||||
.catch(error => {
|
||||
console.error(`Failed to download logo image from ${logoUrl}:`, error);
|
||||
throw new Error(`Failed to create box art: logo image unavailable`);
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
const bgImage = sharp(bgBuf);
|
||||
const meta = await bgImage.metadata();
|
||||
if (!meta.width || !meta.height) throw new Error('Invalid background dimensions');
|
||||
const size = Math.min(meta.width, meta.height);
|
||||
const left = Math.floor((meta.width - size) / 2);
|
||||
const top = Math.floor((meta.height - size) / 2);
|
||||
const squareBg = bgImage.extract({ left, top, width: size, height: size });
|
||||
|
||||
// Resize logo
|
||||
const logoTarget = Math.floor(size * logoPercent);
|
||||
const logoResized = await sharp(logoBuf).resize({ width: logoTarget }).toBuffer();
|
||||
const logoMeta = await sharp(logoResized).metadata();
|
||||
if (!logoMeta.width || !logoMeta.height) throw new Error('Invalid logo dimensions');
|
||||
const logoLeft = Math.floor((size - logoMeta.width) / 2);
|
||||
const logoTop = Math.floor((size - logoMeta.height) / 2);
|
||||
|
||||
return await squareBg
|
||||
.composite([{ input: logoResized, left: logoLeft, top: logoTop }])
|
||||
.jpeg({ quality: 100 })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch JSON from the given URL, with Steam-like headers
|
||||
*/
|
||||
export async function fetchApi<T>(url: string, retries = 3): Promise<T> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < retries; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
agent: (_parsed) => _parsed.protocol === 'http:' ? httpAgent : httpsAgent,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"User-Agent": "Steam 1291812 / iPhone",
|
||||
"Accept-Language": "en-us",
|
||||
},
|
||||
} as RequestInit);
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
} catch (error: any) {
|
||||
lastError = error as Error;
|
||||
// Only retry on network errors or 5xx status codes
|
||||
if (error.message.includes('API error: 5') || !error.message.includes('API error')) {
|
||||
console.warn(`Attempt ${attempt + 1} failed for ${url}: ${error.message}`);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt)));
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error(`Failed to fetch ${url} after ${retries} attempts`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a slug from a name
|
||||
*/
|
||||
export function createSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.normalize("NFKD") // Normalize to decompose accented characters
|
||||
.replace(/[^\p{L}\p{N}\s-]/gu, '') // Keep Unicode letters, numbers, spaces, and hyphens
|
||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||
.replace(/-+/g, '-') // Collapse multiple hyphens
|
||||
.replace(/^-+|-+$/g, '') // Trim leading/trailing hyphens
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a candidate screenshot against a UI-free baseline to find how much UI/HUD remains.
|
||||
*
|
||||
* @param baselineBuffer - PNG/JPEG buffer of the clean background.
|
||||
* @param candidateBuffer - PNG/JPEG buffer of the screenshot to test.
|
||||
* @param opts - Options.
|
||||
* @returns Promise resolving to diff ratio (and optional diff image).
|
||||
*/
|
||||
export async function compareWithBaseline(
|
||||
baselineBuffer: Buffer,
|
||||
candidateBuffer: Buffer,
|
||||
opts: CompareOpts = {}
|
||||
): Promise<CompareResult> {
|
||||
// Generate cache key from buffer hashes
|
||||
const baseHash = crypto.createHash('md5').update(baselineBuffer).digest('hex');
|
||||
const candHash = crypto.createHash('md5').update(candidateBuffer).digest('hex');
|
||||
const optsKey = JSON.stringify(opts);
|
||||
const cacheKey = `${baseHash}:${candHash}:${optsKey}`;
|
||||
|
||||
// Check cache
|
||||
if (compareCache.has(cacheKey)) {
|
||||
return compareCache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
const { threshold = 0.1, diffOutput = false } = opts;
|
||||
|
||||
// Get dimensions of baseline
|
||||
const baseMeta: Metadata = await sharp(baselineBuffer).metadata();
|
||||
if (!baseMeta.width || !baseMeta.height) {
|
||||
throw new Error('Invalid baseline dimensions');
|
||||
}
|
||||
|
||||
// Produce PNG buffers of same size
|
||||
const [pngBaseBuf, pngCandBuf] = await Promise.all([
|
||||
sharp(baselineBuffer).png().toBuffer(),
|
||||
sharp(candidateBuffer)
|
||||
.resize(baseMeta.width, baseMeta.height)
|
||||
.png()
|
||||
.toBuffer(),
|
||||
]);
|
||||
|
||||
const imgBase = PNG.sync.read(pngBaseBuf);
|
||||
const imgCand = PNG.sync.read(pngCandBuf);
|
||||
const diffImg = new PNG({ width: baseMeta.width, height: baseMeta.height });
|
||||
|
||||
const numDiff = pixelmatch(
|
||||
imgBase.data,
|
||||
imgCand.data,
|
||||
diffImg.data,
|
||||
baseMeta.width,
|
||||
baseMeta.height,
|
||||
{ threshold }
|
||||
);
|
||||
|
||||
const total = baseMeta.width * baseMeta.height;
|
||||
const diffRatio = numDiff / total;
|
||||
|
||||
const result: CompareResult = { diffRatio };
|
||||
if (diffOutput) {
|
||||
result.diffBuffer = PNG.sync.write(diffImg);
|
||||
}
|
||||
|
||||
compareCache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a baseline buffer and an array of screenshots, returns them sorted
|
||||
* ascending by diffRatio (least UI first).
|
||||
*/
|
||||
export async function rankScreenshots(
|
||||
baselineBuffer: Buffer,
|
||||
shots: Shot[],
|
||||
opts: CompareOpts = {}
|
||||
): Promise<RankedShot[]> {
|
||||
// Process up to 5 comparisons in parallel
|
||||
const compareLimit = pLimit(5);
|
||||
|
||||
// Run all comparisons with limited concurrency
|
||||
const results = await Promise.all(
|
||||
shots.map(shot =>
|
||||
compareLimit(async () => {
|
||||
const { diffRatio } = await compareWithBaseline(
|
||||
baselineBuffer,
|
||||
shot.buffer,
|
||||
opts
|
||||
);
|
||||
return { url: shot.url, score: diffRatio };
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return results.sort((a, b) => a.score - b.score);
|
||||
}
|
||||
|
||||
// --- Helpers for URLs ---
|
||||
export function getScreenshotUrls(screenshots: { appid: number; filename: string }[]): string[] {
|
||||
return screenshots.map(s => `https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${s.appid}/${s.filename}`);
|
||||
}
|
||||
|
||||
export function getAssetUrls(assets: LibraryAssetsFull, appid: number | string, header: string) {
|
||||
const base = `https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${appid}`;
|
||||
return {
|
||||
logo: `${base}/${assets.library_logo?.image2x?.english || assets.library_logo?.image?.english}`,
|
||||
backdrop: `${base}/${assets.library_hero?.image2x?.english || assets.library_hero?.image?.english}`,
|
||||
poster: `${base}/${assets.library_capsule?.image2x?.english || assets.library_capsule?.image?.english}`,
|
||||
banner: `${base}/${assets.library_header?.image2x?.english || assets.library_header?.image?.english || header}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a 0–5 score from positive/negative votes using a Wilson score confidence interval.
|
||||
* This formula adjusts the raw ratio based on the total number of votes to account for
|
||||
* statistical confidence. With few votes, the score regresses toward 2.5 (neutral).
|
||||
*
|
||||
* Compute a 0–5 score from positive/negative votes
|
||||
*/
|
||||
export function getRating(positive: number, negative: number): number {
|
||||
const total = positive + negative;
|
||||
if (!total) return 0;
|
||||
const avg = positive / total;
|
||||
// Apply Wilson score confidence adjustment and scale to 0-5 range
|
||||
const score = avg - (avg - 0.5) * Math.pow(2, -Math.log10(total + 1));
|
||||
return Math.round(score * 5 * 10) / 10;
|
||||
}
|
||||
|
||||
export function getAssociationsByTypeWithSlug<
|
||||
T extends "developer" | "publisher"
|
||||
>(
|
||||
associations: Record<string, { name: string; type: string }>,
|
||||
type: T
|
||||
): Array<{ name: string; slug: string; type: T }> {
|
||||
return Object.values(associations)
|
||||
.filter((a) => a.type === type)
|
||||
.map((a) => ({ name: a.name.trim(), slug: createSlug(a.name.trim()), type }));
|
||||
}
|
||||
|
||||
export function compatibilityType(type?: string): "low" | "mid" | "high" | "unknown" {
|
||||
switch (type) {
|
||||
case "1":
|
||||
return "high";
|
||||
case "2":
|
||||
return "mid";
|
||||
case "3":
|
||||
return "low";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function estimateRatingFromSummary(
|
||||
reviewCount: number,
|
||||
percentPositive: number
|
||||
): number {
|
||||
const positiveVotes = Math.round((percentPositive / 100) * reviewCount);
|
||||
const negativeVotes = reviewCount - positiveVotes;
|
||||
return getRating(positiveVotes, negativeVotes);
|
||||
}
|
||||
|
||||
export function mapGameTags<
|
||||
T extends string = "tag"
|
||||
>(
|
||||
available: Tag[],
|
||||
storeTags: StoreTags,
|
||||
): Array<{ name: string; slug: string; type: T }> {
|
||||
const tagMap = new Map<number, Tag>(available.map((t) => [t.tagid, t]));
|
||||
const result: Array<{ name: string; slug: string; type: T }> = Object.values(storeTags)
|
||||
.map((id) => tagMap.get(Number(id)))
|
||||
.filter((t): t is Tag => Boolean(t))
|
||||
.map((t) => ({ name: t.name.trim(), slug: createSlug(t.name), type: 'tag' as T }));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createType<
|
||||
T extends "developer" | "publisher" | "franchise" | "tag" | "categorie" | "genre"
|
||||
>(
|
||||
names: string[],
|
||||
type: T
|
||||
) {
|
||||
return names
|
||||
.map(name => ({
|
||||
type,
|
||||
name: name.trim(),
|
||||
slug: createSlug(name.trim())
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tag object with name, slug, and type
|
||||
* @typeparam T Literal type of the `type` field (defaults to 'tag')
|
||||
*/
|
||||
export function createTag<
|
||||
T extends string = 'tag'
|
||||
>(
|
||||
name: string,
|
||||
type?: T
|
||||
): { name: string; slug: string; type: T } {
|
||||
const tagType = (type ?? 'tag') as T;
|
||||
return {
|
||||
name: name.trim(),
|
||||
slug: createSlug(name),
|
||||
type: tagType,
|
||||
};
|
||||
}
|
||||
|
||||
export function capitalise(name: string) {
|
||||
return name
|
||||
.charAt(0) // first character
|
||||
.toUpperCase() // make it uppercase
|
||||
+ name
|
||||
.slice(1) // rest of the string
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function isDepotEntry(e: any): e is DepotEntry {
|
||||
return (
|
||||
e != null &&
|
||||
typeof e === 'object' &&
|
||||
'manifests' in e &&
|
||||
e.manifests != null &&
|
||||
typeof e.manifests.public?.download === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export function getPublicDepotSizes(depots: AppDepots) {
|
||||
let download = 0;
|
||||
let size = 0;
|
||||
|
||||
for (const key of Object.keys(depots)) {
|
||||
if (key === 'branches' || key === 'privatebranches') continue;
|
||||
const entry = depots[key] as DepotEntry;
|
||||
if (!isDepotEntry(entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dl = Number(entry.manifests.public.download);
|
||||
const sz = Number(entry.manifests.public.size);
|
||||
if (!Number.isFinite(dl) || !Number.isFinite(sz)) {
|
||||
console.warn(`[getPublicDepotSizes] non-numeric size for depot ${key}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
download += dl;
|
||||
size += sz;
|
||||
}
|
||||
|
||||
return { downloadSize: download, sizeOnDisk: size };
|
||||
}
|
||||
|
||||
export function parseGenres(str: string): GenreType[] {
|
||||
return str.split(',')
|
||||
.map((g) => g.trim())
|
||||
.filter(Boolean)
|
||||
.map((g) => ({ type: 'genre', name: g.trim(), slug: createSlug(g) }));
|
||||
}
|
||||
|
||||
export function getPrimaryGenre(
|
||||
genres: GenreType[],
|
||||
map: Record<string, string>,
|
||||
primaryId: string
|
||||
): string | null {
|
||||
const idx = Object.keys(map).find((k) => map[k] === primaryId);
|
||||
return idx !== undefined ? genres[Number(idx)]?.name : null;
|
||||
}
|
||||
|
||||
export function cleanDescription(input: string): string {
|
||||
|
||||
const cleaned = sanitizeHtml(input, {
|
||||
allowedTags: [], // no tags allowed
|
||||
allowedAttributes: {}, // no attributes anywhere
|
||||
textFilter: (text) => text.replace(/\s+/g, ' '), // collapse runs of whitespace
|
||||
});
|
||||
|
||||
return cleaned.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches and parses a single Steam community profile XML.
|
||||
* @param steamIdOrVanity - The 64-bit SteamID or vanity name.
|
||||
* @returns Promise resolving to ProfileInfo.
|
||||
*/
|
||||
export async function fetchProfileInfo(
|
||||
steamIdOrVanity: string
|
||||
): Promise<ProfileInfo> {
|
||||
const isNumericId = /^\d+$/.test(steamIdOrVanity);
|
||||
const path = isNumericId ? `profiles/${steamIdOrVanity}` : `id/${steamIdOrVanity}`;
|
||||
const url = `https://steamcommunity.com/${path}/?xml=1`;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${steamIdOrVanity}: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const xml = await response.text();
|
||||
const { profile } = await parseStringPromise(xml, {
|
||||
explicitArray: false,
|
||||
trim: true,
|
||||
mergeAttrs: true
|
||||
}) as { profile: any };
|
||||
|
||||
// Extract fields (fall back to limitedAccount tag if needed)
|
||||
const limitedFlag = profile.isLimitedAccount ?? profile.limitedAccount;
|
||||
const isLimited = limitedFlag === '1';
|
||||
|
||||
return {
|
||||
isLimited,
|
||||
steamID64: profile.steamID64,
|
||||
privacyState: profile.privacyState,
|
||||
visibility: profile.visibilityState
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-fetches multiple Steam profiles in parallel.
|
||||
* @param idsOrVanities - Array of SteamID64 strings or vanity names.
|
||||
* @returns Promise resolving to a record mapping each input to its ProfileInfo or an error.
|
||||
*/
|
||||
export async function fetchProfilesInfo(
|
||||
idsOrVanities: string[]
|
||||
): Promise<Map<string, ProfileInfo | { error: string }>> {
|
||||
const results = await Promise.all(
|
||||
idsOrVanities.map(async (input) => {
|
||||
try {
|
||||
const info = await fetchProfileInfo(input);
|
||||
return { input, result: info };
|
||||
} catch (err) {
|
||||
return { input, result: { error: (err as Error).message } };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return new Map(
|
||||
results.map(({ input, result }) => [input, result] as [string, ProfileInfo | { error: string }])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import "zod-openapi/extend";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export namespace Common {
|
||||
export const IdDescription = `Unique object identifier.
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
export function createContext<T>(name: string) {
|
||||
export function createContext<T>() {
|
||||
const storage = new AsyncLocalStorage<T>();
|
||||
return {
|
||||
use() {
|
||||
const result = storage.getStore();
|
||||
if (!result) {
|
||||
throw new Error("Context not provided: " + name);
|
||||
throw new Error("No context available");
|
||||
}
|
||||
return result;
|
||||
},
|
||||
with<R>(value: T, fn: () => R) {
|
||||
return storage.run<R, any[]>(value, fn);
|
||||
provide<R>(value: T, fn: () => R) {
|
||||
return storage.run<R>(value, fn);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from "drizzle-orm";
|
||||
import { Resource } from "sst";
|
||||
import postgres from "postgres";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
|
||||
@@ -20,7 +20,7 @@ type TxOrDb = Transaction | typeof db;
|
||||
const TransactionContext = createContext<{
|
||||
tx: Transaction;
|
||||
effects: (() => void | Promise<void>)[];
|
||||
}>("TransactionContext");
|
||||
}>();
|
||||
|
||||
export async function useTransaction<T>(callback: (trx: TxOrDb) => Promise<T>) {
|
||||
try {
|
||||
@@ -51,7 +51,7 @@ export async function createTransaction<T>(
|
||||
const effects: (() => void | Promise<void>)[] = [];
|
||||
const result = await db.transaction(
|
||||
async (tx) => {
|
||||
return TransactionContext.with({ tx, effects }, () => callback(tx));
|
||||
return TransactionContext.provide({ tx, effects }, () => callback(tx));
|
||||
},
|
||||
{
|
||||
isolationLevel: isolationLevel || "read committed",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { char, timestamp as rawTs } from "drizzle-orm/pg-core";
|
||||
import { teamTable } from "../team/team.sql";
|
||||
|
||||
export const ulid = (name: string) => char(name, { length: 26 + 4 });
|
||||
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
import { useActor } from "./actor";
|
||||
import { event as sstEvent } from "sst/event";
|
||||
import { Actor } from "./actor";
|
||||
import { event } from "sst/event";
|
||||
import { ZodValidator } from "sst/event/validator";
|
||||
|
||||
export const createEvent = sstEvent.builder({
|
||||
export const createEvent = event.builder({
|
||||
validator: ZodValidator,
|
||||
metadata() {
|
||||
return {
|
||||
actor: useActor(),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
import { openevent } from "@openauthjs/openevent/event";
|
||||
export { publish } from "@openauthjs/openevent/publisher/drizzle";
|
||||
|
||||
export const event = openevent({
|
||||
metadata() {
|
||||
return {
|
||||
actor: useActor(),
|
||||
actor: Actor.use(),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,76 +1,30 @@
|
||||
import { prefixes } from "./utils";
|
||||
|
||||
export namespace Examples {
|
||||
export const Id = (prefix: keyof typeof prefixes) =>
|
||||
`${prefixes[prefix]}_XXXXXXXXXXXXXXXXXXXXXXXXX`;
|
||||
|
||||
export const Steam = {
|
||||
id: Id("steam"),
|
||||
userID: Id("user"),
|
||||
countryCode: "KE",
|
||||
steamID: 74839300282033,
|
||||
limitation: {
|
||||
isLimited: false,
|
||||
isBanned: false,
|
||||
isLocked: false,
|
||||
isAllowedToInviteFriends: false,
|
||||
},
|
||||
lastGame: {
|
||||
gameID: 2531310,
|
||||
gameName: "The Last of Us™ Part II Remastered",
|
||||
},
|
||||
personaName: "John",
|
||||
username: "johnsteamaccount",
|
||||
steamEmail: "john@example.com",
|
||||
avatarUrl: "https://avatars.akamai.steamstatic.com/XXXXXXXXXXXX_full.jpg",
|
||||
}
|
||||
|
||||
export const User = {
|
||||
id: Id("user"),
|
||||
name: "John Doe",
|
||||
email: "john@example.com",
|
||||
discriminator: 47,
|
||||
id: Id("user"),// Primary key
|
||||
name: "John Doe", // Name (not null)
|
||||
email: "johndoe@example.com",// Unique email or login (not null)
|
||||
avatarUrl: "https://cdn.discordapp.com/avatars/xxxxxxx/xxxxxxx.png",
|
||||
polarCustomerID: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
steamAccounts: [Steam]
|
||||
};
|
||||
|
||||
export const Product = {
|
||||
id: Id("product"),
|
||||
name: "RTX 4090",
|
||||
description: "Ideal for dedicated gamers who crave more flexibility and social gaming experiences.",
|
||||
tokensPerHour: 20,
|
||||
lastLogin: new Date("2025-04-26T20:11:08.155Z"),
|
||||
polarCustomerID: "0bfcb712-df13-4454-81a8-fbee66eddca4"
|
||||
}
|
||||
|
||||
export const Subscription = {
|
||||
tokens: 100,
|
||||
id: Id("subscription"),
|
||||
userID: Id("user"),
|
||||
teamID: Id("team"),
|
||||
planType: "pro" as const, // free, pro, family, enterprise
|
||||
standing: "new" as const, // new, good, overdue, cancelled
|
||||
polarProductID: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
polarSubscriptionID: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
}
|
||||
|
||||
export const Member = {
|
||||
id: Id("member"),
|
||||
email: "john@example.com",
|
||||
teamID: Id("team"),
|
||||
role: "admin" as const,
|
||||
timeSeen: new Date("2025-02-23T13:39:52.249Z"),
|
||||
}
|
||||
|
||||
export const Team = {
|
||||
id: Id("team"),
|
||||
name: "John Does' Team",
|
||||
slug: "john_doe",
|
||||
subscriptions: [Subscription],
|
||||
members: [Member]
|
||||
export const GPUType = {
|
||||
id: Id("gpu"),
|
||||
type: "hosted" as const, //or BYOG - Bring Your Own GPU
|
||||
name: "RTX 4090" as const, // or RTX 3090, Intel Arc
|
||||
performanceTier: 3,
|
||||
maxResolution: "4k"
|
||||
}
|
||||
|
||||
export const Machine = {
|
||||
id: Id("machine"),
|
||||
userID: Id("user"),
|
||||
ownerID: User.id, //or null if hosted
|
||||
gpuID: GPUType.id, // or hosted
|
||||
country: "Kenya",
|
||||
countryCode: "KE",
|
||||
timezone: "Africa/Nairobi",
|
||||
@@ -78,4 +32,244 @@ export namespace Examples {
|
||||
fingerprint: "fc27f428f9ca47d4b41b707ae0c62090",
|
||||
}
|
||||
|
||||
export const SteamAccount = {
|
||||
status: "online" as const, //offline,dnd(do not disturb) or playing
|
||||
id: "74839300282033",// Steam ID
|
||||
userID: User.id,// | null FK to User (null if not linked)
|
||||
name: "JD The 65th",
|
||||
username: "jdoe",
|
||||
realName: "John Doe",
|
||||
steamMemberSince: new Date("2010-01-26T21:00:00.000Z"),
|
||||
avatarHash: "3a5e805fd4c1e04e26a97af0b9c6fab2dee91a19",
|
||||
accountStatus: "new" as const, //active or pending
|
||||
limitations: {
|
||||
isLimited: false,
|
||||
tradeBanState: "none" as const,
|
||||
isVacBanned: false,
|
||||
visibilityState: 3,
|
||||
privacyState: "public" as const,
|
||||
},
|
||||
profileUrl: "The65thJD", //"https://steamcommunity.com/id/XXXXXXXXXXXXXXXX/",
|
||||
lastSyncedAt: new Date("2025-04-26T20:11:08.155Z")
|
||||
};
|
||||
|
||||
export const Team = {
|
||||
id: Id("team"),// Primary key
|
||||
name: "John", // Team name (not null, unique)
|
||||
maxMembers: 3,
|
||||
inviteCode: "xwydjf",
|
||||
ownerSteamID: SteamAccount.id, // FK to User who owns/created the team
|
||||
members: [SteamAccount]
|
||||
};
|
||||
|
||||
export const Member = {
|
||||
id: Id("member"),
|
||||
userID: User.id,//FK to Users (member)
|
||||
steamID: SteamAccount.id, // FK to the Steam Account this member is used
|
||||
teamID: Team.id,// FK to Teams
|
||||
role: "adult" as const, // Role on the team, adult or child
|
||||
};
|
||||
|
||||
export const ProductVariant = {
|
||||
id: Id("variant"),
|
||||
productID: Id("product"),// the product this variant is under
|
||||
type: "fixed" as const, // or yearly or monthly,
|
||||
price: 1999,
|
||||
minutesPerDay: 3600,
|
||||
polarProductID: "0bfcb712-df13-4454-81a8-fbee66eddca4"
|
||||
}
|
||||
|
||||
export const Product = {
|
||||
id: Id("product"),
|
||||
name: "Pro",
|
||||
description: "For gamers who want to play on a better GPU and with 2 more friends",
|
||||
maxMembers: Team.maxMembers,// Total number of people who can share this sub
|
||||
isActive: true,
|
||||
order: 2,
|
||||
variants: [ProductVariant]
|
||||
}
|
||||
|
||||
export const Friend = {
|
||||
...Examples.SteamAccount,
|
||||
user: Examples.User
|
||||
}
|
||||
|
||||
export const Subscription = {
|
||||
id: Id("subscription"),
|
||||
teamID: Team.id,
|
||||
standing: "active" as const, //incomplete, incomplete_expired, trialing, active, past_due, canceled, unpaid
|
||||
ownerID: User.id,
|
||||
price: ProductVariant.price,
|
||||
productVariantID: ProductVariant.id,
|
||||
polarSubscriptionID: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
}
|
||||
|
||||
export const SubscriptionUsage = {
|
||||
id: Id("usage"),
|
||||
machineID: Machine.id, // machine this session was used on
|
||||
memberID: Member.id, // the team member who used it
|
||||
subscriptionID: Subscription.id,
|
||||
sessionID: Id("session"),
|
||||
minutesUsed: 20, // Minutes used on the session
|
||||
}
|
||||
|
||||
export const Session = {
|
||||
id: Id("session"),
|
||||
memberID: Member.id,
|
||||
machineID: Machine.id,
|
||||
startTime: new Date("2025-02-23T23:39:52.249Z"),
|
||||
endTime: null, // null if session is ongoing
|
||||
gameID: Id("game"),
|
||||
status: "active" as const, // active, completed, crashed
|
||||
}
|
||||
|
||||
export const GameGenre = {
|
||||
type: "genre" as const,
|
||||
slug: "action",
|
||||
name: "Action"
|
||||
}
|
||||
|
||||
export const GameTag = {
|
||||
type: "tag" as const,
|
||||
slug: "single-player",
|
||||
name: "Single Player"
|
||||
}
|
||||
|
||||
export const GameRating = {
|
||||
body: "ESRB" as const, // or PEGI
|
||||
age: 16,
|
||||
descriptors: ["Blood", "Violence", "Strong Language"],
|
||||
}
|
||||
|
||||
export const DevelopmentTeam = {
|
||||
type: "developer" as const,
|
||||
name: "Remedy Entertainment",
|
||||
slug: "remedy_entertainment",
|
||||
}
|
||||
|
||||
export const BaseGame = {
|
||||
id: "1809540",
|
||||
slug: "nine-sols",
|
||||
name: "Nine Sols",
|
||||
links:[
|
||||
"https://example.com"
|
||||
],
|
||||
controllerSupport: "full" as const,
|
||||
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"
|
||||
}
|
||||
],
|
||||
franchises: [],
|
||||
categories: [
|
||||
{
|
||||
name: "Partial Controller",
|
||||
slug: "partial-controller"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export const CommonImg = [
|
||||
{
|
||||
hash: "db880dc2f0187bfe0c5d3c44a06d1002351eb3107970a83bf5667ffd3b369acd",
|
||||
averageColor: {
|
||||
hex: "#352c36",
|
||||
isDark: true
|
||||
},
|
||||
dimensions: {
|
||||
width: 3840,
|
||||
height: 2160
|
||||
},
|
||||
fileSize: 976004
|
||||
},
|
||||
{
|
||||
hash: "99f603e41dd3efde21a145fd00c9f107025c09433c084a5e5005bc2ac30e46ea",
|
||||
averageColor: {
|
||||
hex: "#596774",
|
||||
isDark: true
|
||||
},
|
||||
dimensions: {
|
||||
width: 2560,
|
||||
height: 1440
|
||||
},
|
||||
fileSize: 895134
|
||||
},
|
||||
{
|
||||
hash: "2c4193c19160392be01d08e6957ed682649117742c5abaa8c469e7408382572f",
|
||||
averageColor: {
|
||||
hex: "#444b5b",
|
||||
isDark: true
|
||||
},
|
||||
dimensions: {
|
||||
width: 2560,
|
||||
height: 1440
|
||||
},
|
||||
fileSize: 738701
|
||||
}
|
||||
]
|
||||
|
||||
// type: "screenshots" as const, // or boxart(square), poster(vertical), superheroart(background), heroart(horizontal), logo, icon
|
||||
export const Images = {
|
||||
screenshots: CommonImg,
|
||||
boxArts: CommonImg,
|
||||
posters: CommonImg,
|
||||
banners: CommonImg,
|
||||
heroArts: CommonImg,
|
||||
backdrops: CommonImg,
|
||||
logos: CommonImg,
|
||||
icons: CommonImg,
|
||||
}
|
||||
|
||||
export const Game = {
|
||||
...BaseGame,
|
||||
...Categories,
|
||||
...Images
|
||||
}
|
||||
}
|
||||
26
packages/core/src/friend/friend.sql.ts
Normal file
26
packages/core/src/friend/friend.sql.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { timestamps, } from "../drizzle/types";
|
||||
import { steamTable } from "../steam/steam.sql";
|
||||
import { index, pgTable, primaryKey, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const friendTable = pgTable(
|
||||
"friends_list",
|
||||
{
|
||||
...timestamps,
|
||||
steamID: varchar("steam_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => steamTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
friendSteamID: varchar("friend_steam_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => steamTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.steamID, table.friendSteamID]
|
||||
}),
|
||||
index("idx_friends_list_friend_steam_id").on(table.friendSteamID),
|
||||
]
|
||||
);
|
||||
190
packages/core/src/friend/index.ts
Normal file
190
packages/core/src/friend/index.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { User } from "../user";
|
||||
import { Steam } from "../steam";
|
||||
import { Actor } from "../actor";
|
||||
import { Examples } from "../examples";
|
||||
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 { ErrorCodes, VisibleError } from "../error";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Friend {
|
||||
export const Info = Steam.Info
|
||||
.extend({
|
||||
user: User.Info.nullable().openapi({
|
||||
description: "The user account that owns this Steam account",
|
||||
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) =>
|
||||
createTransaction(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
|
||||
})
|
||||
.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))
|
||||
)
|
||||
|
||||
export const fromFriendID = fn(
|
||||
InputInfo.shape.friendSteamID,
|
||||
(friendSteamID) =>
|
||||
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()),
|
||||
eq(friendTable.friendSteamID, friendSteamID),
|
||||
isNull(friendTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.execute()
|
||||
.then(rows => serialize(rows).at(0))
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
export const areFriends = fn(
|
||||
InputInfo.shape.friendSteamID,
|
||||
(friendSteamID) =>
|
||||
useTransaction(async (tx) => {
|
||||
const result = await tx
|
||||
.select()
|
||||
.from(friendTable)
|
||||
.where(
|
||||
and(
|
||||
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
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
35
packages/core/src/game/game.sql.ts
Normal file
35
packages/core/src/game/game.sql.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { timestamps } from "../drizzle/types";
|
||||
import { baseGamesTable } from "../base-game/base-game.sql";
|
||||
import { categoriesTable, CategoryTypeEnum } from "../categories/categories.sql";
|
||||
import { foreignKey, 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(),
|
||||
categoryType: CategoryTypeEnum("type").notNull()
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.baseGameID, table.categorySlug, table.categoryType]
|
||||
}),
|
||||
foreignKey({
|
||||
name: "games_categories_fkey",
|
||||
columns: [table.categorySlug, table.categoryType],
|
||||
foreignColumns: [categoriesTable.slug, categoriesTable.type],
|
||||
}).onDelete("cascade"),
|
||||
index("idx_games_category_slug").on(table.categorySlug),
|
||||
index("idx_games_category_type").on(table.categoryType),
|
||||
index("idx_games_category_slug_type").on(
|
||||
table.categorySlug,
|
||||
table.categoryType
|
||||
)
|
||||
]
|
||||
);
|
||||
129
packages/core/src/game/index.ts
Normal file
129
packages/core/src/game/index.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Images } from "../images";
|
||||
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 { imagesTable } from "../images/images.sql";
|
||||
import { baseGamesTable } from "../base-game/base-game.sql";
|
||||
import { groupBy, map, pipe, uniqueBy, values } from "remeda";
|
||||
import { categoriesTable } from "../categories/categories.sql";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Game {
|
||||
export const Info = z
|
||||
.intersection(BaseGame.Info, Categories.Info, Images.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 result =
|
||||
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)
|
||||
)
|
||||
)
|
||||
.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
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
46
packages/core/src/images/images.sql.ts
Normal file
46
packages/core/src/images/images.sql.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { z } from "zod";
|
||||
import { timestamps } from "../drizzle/types";
|
||||
import { baseGamesTable } from "../base-game/base-game.sql";
|
||||
import { index, integer, json, pgEnum, pgTable, primaryKey, text, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const ImageTypeEnum = pgEnum("image_type", ["heroArt", "icon", "logo", "banner", "poster", "boxArt", "screenshot", "backdrop"])
|
||||
|
||||
export const ImageDimensions = z.object({
|
||||
width: z.number().int(),
|
||||
height: z.number().int(),
|
||||
})
|
||||
|
||||
export const ImageColor = z.object({
|
||||
hex: z.string(),
|
||||
isDark: z.boolean()
|
||||
})
|
||||
|
||||
export type ImageColor = z.infer<typeof ImageColor>;
|
||||
export type ImageDimensions = z.infer<typeof ImageDimensions>;
|
||||
|
||||
export const imagesTable = pgTable(
|
||||
"images",
|
||||
{
|
||||
...timestamps,
|
||||
type: ImageTypeEnum("type").notNull(),
|
||||
imageHash: varchar("image_hash", { length: 255 })
|
||||
.notNull(),
|
||||
baseGameID: varchar("base_game_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => baseGamesTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
sourceUrl: text("source_url"), // The BoxArt is source Url will always be null;
|
||||
position: integer("position").notNull().default(0),
|
||||
fileSize: integer("file_size").notNull(),
|
||||
dimensions: json("dimensions").$type<ImageDimensions>().notNull(),
|
||||
extractedColor: json("extracted_color").$type<ImageColor>().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.imageHash, table.type, table.baseGameID, table.position]
|
||||
}),
|
||||
index("idx_images_type").on(table.type),
|
||||
index("idx_images_game_id").on(table.baseGameID),
|
||||
]
|
||||
)
|
||||
119
packages/core/src/images/index.ts
Normal file
119
packages/core/src/images/index.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Examples } from "../examples";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { createTransaction } from "../drizzle/transaction";
|
||||
import { ImageColor, ImageDimensions, imagesTable } from "./images.sql";
|
||||
|
||||
export namespace Images {
|
||||
const Image = z.object({
|
||||
hash: z.string().openapi({
|
||||
description: "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",
|
||||
example: Examples.CommonImg[0].averageColor
|
||||
}),
|
||||
dimensions: ImageDimensions.openapi({
|
||||
description: "The width and height dimensions of the image in pixels",
|
||||
example: Examples.CommonImg[0].dimensions
|
||||
}),
|
||||
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 type Info = z.infer<typeof Info>
|
||||
|
||||
export const InputInfo = createSelectSchema(imagesTable)
|
||||
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true })
|
||||
|
||||
export const create = fn(
|
||||
InputInfo,
|
||||
(input) =>
|
||||
createTransaction(async (tx) =>
|
||||
tx
|
||||
.insert(imagesTable)
|
||||
.values(input)
|
||||
.onConflictDoUpdate({
|
||||
target: [imagesTable.imageHash, imagesTable.type, imagesTable.baseGameID, imagesTable.position],
|
||||
set: { timeDeleted: null }
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
export function serialize(
|
||||
input: typeof imagesTable.$inferSelect[],
|
||||
): z.infer<typeof Info> {
|
||||
return input
|
||||
.sort((a, b) => {
|
||||
if (a.type === b.type) {
|
||||
return a.position - b.position;
|
||||
}
|
||||
return a.type.localeCompare(b.type);
|
||||
})
|
||||
.reduce<Record<`${typeof imagesTable.$inferSelect["type"]}s`, { hash: string; averageColor: ImageColor; dimensions: ImageDimensions; fileSize: number }[]>>((acc, img) => {
|
||||
const key = `${img.type}s` as `${typeof img.type}s`
|
||||
if (Array.isArray(acc[key])) {
|
||||
acc[key]!.push({
|
||||
hash: img.imageHash,
|
||||
averageColor: img.extractedColor,
|
||||
dimensions: img.dimensions,
|
||||
fileSize: img.fileSize
|
||||
})
|
||||
}
|
||||
return acc
|
||||
}, {
|
||||
screenshots: [],
|
||||
boxArts: [],
|
||||
banners: [],
|
||||
heroArts: [],
|
||||
posters: [],
|
||||
backdrops: [],
|
||||
icons: [],
|
||||
logos: [],
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
138
packages/core/src/library/index.ts
Normal file
138
packages/core/src/library/index.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Game } from "../game";
|
||||
import { Actor } from "../actor";
|
||||
import { createEvent } from "../event";
|
||||
import { gamesTable } from "../game/game.sql";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { steamLibraryTable } from "./library.sql";
|
||||
import { imagesTable } from "../images/images.sql";
|
||||
import { and, eq, 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";
|
||||
|
||||
export namespace Library {
|
||||
export const Info = createSelectSchema(steamLibraryTable)
|
||||
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true })
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const Events = {
|
||||
Add: createEvent(
|
||||
"library.add",
|
||||
z.object({
|
||||
appID: z.number(),
|
||||
lastPlayed: z.date().nullable(),
|
||||
totalPlaytime: z.number(),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
export const add = fn(
|
||||
Info.partial({ ownerSteamID: true }),
|
||||
async (input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const ownerSteamID = input.ownerSteamID ?? Actor.steamID()
|
||||
const result =
|
||||
await tx
|
||||
.select()
|
||||
.from(steamLibraryTable)
|
||||
.where(
|
||||
and(
|
||||
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))
|
||||
)
|
||||
|
||||
}
|
||||
29
packages/core/src/library/library.sql.ts
Normal file
29
packages/core/src/library/library.sql.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { steamTable } from "../steam/steam.sql";
|
||||
import { timestamps, utc, } from "../drizzle/types";
|
||||
import { baseGamesTable } from "../base-game/base-game.sql";
|
||||
import { index, integer, pgTable, primaryKey, varchar, } from "drizzle-orm/pg-core";
|
||||
|
||||
export const steamLibraryTable = pgTable(
|
||||
"game_libraries",
|
||||
{
|
||||
...timestamps,
|
||||
baseGameID: varchar("base_game_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => baseGamesTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
ownerSteamID: varchar("owner_steam_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => steamTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
lastPlayed: utc("last_played"),
|
||||
totalPlaytime: integer("total_playtime").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.baseGameID, table.ownerSteamID]
|
||||
}),
|
||||
index("idx_game_libraries_owner_id").on(table.ownerSteamID),
|
||||
],
|
||||
);
|
||||
@@ -1,155 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { Common } from "../common";
|
||||
import { createID, fn } from "../utils";
|
||||
import { Examples } from "../examples";
|
||||
import { machineTable } from "./machine.sql";
|
||||
import { getTableColumns, eq, sql, and, isNull } from "../drizzle";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Machine {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Machine.id,
|
||||
}),
|
||||
// userID: z.string().nullable().openapi({
|
||||
// description: "The userID of the user who owns this machine, in the case of BYOG",
|
||||
// example: Examples.Machine.userID
|
||||
// }),
|
||||
country: z.string().openapi({
|
||||
description: "The fullname of the country this machine is running in",
|
||||
example: Examples.Machine.country
|
||||
}),
|
||||
fingerprint: z.string().openapi({
|
||||
description: "The fingerprint of this machine, deduced from the host machine's machine id - /etc/machine-id",
|
||||
example: Examples.Machine.fingerprint
|
||||
}),
|
||||
location: z.object({ longitude: z.number(), latitude: z.number() }).openapi({
|
||||
description: "This is the 2d location of this machine, they might not be accurate",
|
||||
example: Examples.Machine.location
|
||||
}),
|
||||
countryCode: z.string().openapi({
|
||||
description: "This is the 2 character country code of the country this machine [ISO 3166-1 alpha-2] ",
|
||||
example: Examples.Machine.countryCode
|
||||
}),
|
||||
timezone: z.string().openapi({
|
||||
description: "The IANA timezone formatted string of the timezone of the location where the machine is running",
|
||||
example: Examples.Machine.timezone
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Machine",
|
||||
description: "Represents a hosted or BYOG machine connected to Nestri",
|
||||
example: Examples.Machine,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const create = fn(Info.partial({ id: true }), async (input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const id = input.id ?? createID("machine");
|
||||
await tx.insert(machineTable).values({
|
||||
id,
|
||||
country: input.country,
|
||||
timezone: input.timezone,
|
||||
fingerprint: input.fingerprint,
|
||||
countryCode: input.countryCode,
|
||||
// userID: input.userID,
|
||||
location: { x: input.location.longitude, y: input.location.latitude },
|
||||
})
|
||||
|
||||
// await afterTx(() =>
|
||||
// bus.publish(Resource.Bus, Events.Created, {
|
||||
// teamID: id,
|
||||
// }),
|
||||
// );
|
||||
return id;
|
||||
})
|
||||
)
|
||||
|
||||
// export const fromUserID = fn(z.string(), async (userID) =>
|
||||
// useTransaction(async (tx) =>
|
||||
// tx
|
||||
// .select()
|
||||
// .from(machineTable)
|
||||
// .where(and(eq(machineTable.userID, userID), isNull(machineTable.timeDeleted)))
|
||||
// .then((rows) => rows.map(serialize))
|
||||
// )
|
||||
// )
|
||||
|
||||
// export const list = fn(z.void(), async () =>
|
||||
// useTransaction(async (tx) =>
|
||||
// tx
|
||||
// .select()
|
||||
// .from(machineTable)
|
||||
// // Show only hosted machines, not BYOG machines
|
||||
// .where(and(isNull(machineTable.userID), isNull(machineTable.timeDeleted)))
|
||||
// .then((rows) => rows.map(serialize))
|
||||
// )
|
||||
// )
|
||||
|
||||
export const fromID = fn(Info.shape.id, async (id) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(machineTable)
|
||||
.where(and(eq(machineTable.id, id), isNull(machineTable.timeDeleted)))
|
||||
.then((rows) => rows.map(serialize).at(0))
|
||||
)
|
||||
)
|
||||
|
||||
export const fromFingerprint = fn(Info.shape.fingerprint, async (fingerprint) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(machineTable)
|
||||
.where(and(eq(machineTable.fingerprint, fingerprint), isNull(machineTable.timeDeleted)))
|
||||
.execute()
|
||||
.then((rows) => rows.map(serialize).at(0))
|
||||
)
|
||||
)
|
||||
|
||||
export const remove = fn(Info.shape.id, (id) =>
|
||||
useTransaction(async (tx) => {
|
||||
await tx
|
||||
.update(machineTable)
|
||||
.set({
|
||||
timeDeleted: sql`now()`,
|
||||
})
|
||||
.where(and(eq(machineTable.id, id)))
|
||||
.execute();
|
||||
return id;
|
||||
}),
|
||||
);
|
||||
|
||||
export const fromLocation = fn(Info.shape.location, async (location) =>
|
||||
useTransaction(async (tx) => {
|
||||
const sqlDistance = sql`location <-> point(${location.longitude}, ${location.latitude})`;
|
||||
return tx
|
||||
.select({
|
||||
...getTableColumns(machineTable),
|
||||
distance: sql`round((${sqlDistance})::numeric, 2)`
|
||||
})
|
||||
.from(machineTable)
|
||||
.where(isNull(machineTable.timeDeleted))
|
||||
.orderBy(sqlDistance)
|
||||
.limit(3)
|
||||
.then((rows) => rows.map(serialize))
|
||||
})
|
||||
)
|
||||
|
||||
export function serialize(
|
||||
input: typeof machineTable.$inferSelect,
|
||||
): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
// userID: input.userID,
|
||||
country: input.country,
|
||||
timezone: input.timezone,
|
||||
fingerprint: input.fingerprint,
|
||||
countryCode: input.countryCode,
|
||||
location: { latitude: input.location.y, longitude: input.location.x },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { } from "drizzle-orm/postgres-js";
|
||||
import { timestamps, id, ulid } from "../drizzle/types";
|
||||
import {
|
||||
text,
|
||||
varchar,
|
||||
pgTable,
|
||||
uniqueIndex,
|
||||
point,
|
||||
primaryKey,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
export const machineTable = pgTable(
|
||||
"machine",
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
// userID: ulid("user_id"),
|
||||
country: text('country').notNull(),
|
||||
timezone: text('timezone').notNull(),
|
||||
location: point('location', { mode: 'xy' }).notNull(),
|
||||
fingerprint: varchar('fingerprint', { length: 32 }).notNull(),
|
||||
countryCode: varchar('country_code', { length: 2 }).notNull(),
|
||||
// provider: text("provider").notNull(),
|
||||
// gpuType: text("gpu_type").notNull(),
|
||||
// storage: numeric("storage").notNull(),
|
||||
// ipaddress: text("ipaddress").notNull(),
|
||||
// gpuNumber: integer("gpu_number").notNull(),
|
||||
// computePrice: numeric("compute_price").notNull(),
|
||||
// driverVersion: integer("driver_version").notNull(),
|
||||
// operatingSystem: text("operating_system").notNull(),
|
||||
// fingerprint: varchar("fingerprint", { length: 32 }).notNull(),
|
||||
// externalID: varchar("external_id", { length: 255 }).notNull(),
|
||||
// cudaVersion: numeric("cuda_version", { precision: 4, scale: 2 }).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
// uniqueIndex("external_id").on(table.externalID),
|
||||
uniqueIndex("machine_fingerprint").on(table.fingerprint),
|
||||
// primaryKey({ columns: [table.userID, table.id], }),
|
||||
],
|
||||
);
|
||||
@@ -1,139 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { Resource } from "sst";
|
||||
import { bus } from "sst/aws/bus";
|
||||
import { useTeam } from "../actor";
|
||||
import { Common } from "../common";
|
||||
import { createID, fn } from "../utils";
|
||||
import { createEvent } from "../event";
|
||||
import { Examples } from "../examples";
|
||||
import { memberTable, role } from "./member.sql";
|
||||
import { and, eq, sql, asc, isNull } from "../drizzle";
|
||||
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Member {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Member.id,
|
||||
}),
|
||||
timeSeen: z.date().nullable().or(z.undefined()).openapi({
|
||||
description: "The last time this team member was active",
|
||||
example: Examples.Member.timeSeen
|
||||
}),
|
||||
teamID: z.string().openapi({
|
||||
description: "The unique id of the team this member is on",
|
||||
example: Examples.Member.teamID
|
||||
}),
|
||||
role: z.enum(role).openapi({
|
||||
description: "The role of this team member",
|
||||
example: Examples.Member.role
|
||||
}),
|
||||
email: z.string().openapi({
|
||||
description: "The email of this team member",
|
||||
example: Examples.Member.email
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Member",
|
||||
description: "Represents a team member on Nestri",
|
||||
example: Examples.Member,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const Events = {
|
||||
Created: createEvent(
|
||||
"member.created",
|
||||
z.object({
|
||||
memberID: Info.shape.id,
|
||||
}),
|
||||
),
|
||||
Updated: createEvent(
|
||||
"member.updated",
|
||||
z.object({
|
||||
memberID: Info.shape.id,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
export const create = fn(
|
||||
Info.pick({ email: true, id: true })
|
||||
.partial({
|
||||
id: true,
|
||||
})
|
||||
.extend({
|
||||
first: z.boolean().optional(),
|
||||
}),
|
||||
(input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const id = input.id ?? createID("member");
|
||||
await tx.insert(memberTable).values({
|
||||
id,
|
||||
teamID: useTeam(),
|
||||
email: input.email,
|
||||
role: input.first ? "owner" : "member",
|
||||
timeSeen: input.first ? sql`now()` : null,
|
||||
})
|
||||
|
||||
await afterTx(() =>
|
||||
async () => bus.publish(Resource.Bus, Events.Created, { memberID: id }),
|
||||
);
|
||||
return id;
|
||||
}),
|
||||
);
|
||||
|
||||
export const remove = fn(Info.shape.id, (id) =>
|
||||
useTransaction(async (tx) => {
|
||||
await tx
|
||||
.update(memberTable)
|
||||
.set({
|
||||
timeDeleted: sql`now()`,
|
||||
})
|
||||
.where(and(eq(memberTable.id, id), eq(memberTable.teamID, useTeam())))
|
||||
.execute();
|
||||
return id;
|
||||
}),
|
||||
);
|
||||
|
||||
export const fromEmail = fn(z.string(), async (email) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(memberTable)
|
||||
.where(and(eq(memberTable.email, email), isNull(memberTable.timeDeleted)))
|
||||
.orderBy(asc(memberTable.timeCreated))
|
||||
.then((rows) => rows.map(serialize).at(0))
|
||||
)
|
||||
)
|
||||
|
||||
export const fromID = fn(z.string(), async (id) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(memberTable)
|
||||
.where(and(eq(memberTable.id, id), isNull(memberTable.timeDeleted)))
|
||||
.orderBy(asc(memberTable.timeCreated))
|
||||
.then((rows) => rows.map(serialize).at(0))
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Converts a raw member database row into a standardized {@link Member.Info} object.
|
||||
*
|
||||
* @param input - The database row representing a member.
|
||||
* @returns The member information formatted as a {@link Member.Info} object.
|
||||
*/
|
||||
export function serialize(
|
||||
input: typeof memberTable.$inferSelect,
|
||||
): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
role: input.role,
|
||||
email: input.email,
|
||||
teamID: input.teamID,
|
||||
timeSeen: input.timeSeen
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { teamIndexes } from "../team/team.sql";
|
||||
import { timestamps, utc, teamID } from "../drizzle/types";
|
||||
import { index, pgTable, text, uniqueIndex, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const role = ["admin", "member", "owner"] as const;
|
||||
|
||||
export const memberTable = pgTable(
|
||||
"member",
|
||||
{
|
||||
...teamID,
|
||||
...timestamps,
|
||||
role: text("role", { enum: role }).notNull(),
|
||||
timeSeen: utc("time_seen"),
|
||||
email: varchar("email", { length: 255 }).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
...teamIndexes(table),
|
||||
index("email_global").on(table.email),
|
||||
uniqueIndex("member_email").on(table.teamID, table.email),
|
||||
],
|
||||
);
|
||||
@@ -1,13 +1,14 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Resource } from "sst";
|
||||
import { useTeam, useUserID } from "../actor";
|
||||
import { Polar as PolarSdk } from "@polar-sh/sdk";
|
||||
import { validateEvent } from "@polar-sh/sdk/webhooks";
|
||||
import { PlanType } from "../subscription/subscription.sql";
|
||||
|
||||
const polar = new PolarSdk({ accessToken: Resource.PolarSecret.value, server: Resource.App.stage !== "production" ? "sandbox" : "production" });
|
||||
const planType = z.enum(PlanType)
|
||||
const polar = new PolarSdk({
|
||||
accessToken: Resource.PolarSecret.value,
|
||||
server: Resource.App.stage !== "production" ? "sandbox" : "production"
|
||||
});
|
||||
|
||||
export namespace Polar {
|
||||
export const client = polar;
|
||||
|
||||
@@ -16,7 +17,7 @@ export namespace Polar {
|
||||
const customers = await client.customers.list({ email })
|
||||
|
||||
if (customers.result.items.length === 0) {
|
||||
return await client.customers.create({ email })
|
||||
return await client.customers.create({ email})
|
||||
} else {
|
||||
return customers.result.items[0]
|
||||
}
|
||||
@@ -28,18 +29,18 @@ export namespace Polar {
|
||||
}
|
||||
})
|
||||
|
||||
const getProductIDs = (plan: z.infer<typeof planType>) => {
|
||||
switch (plan) {
|
||||
case "free":
|
||||
return [Resource.NestriFreeMonthly.value]
|
||||
case "pro":
|
||||
return [Resource.NestriProYearly.value, Resource.NestriProMonthly.value]
|
||||
case "family":
|
||||
return [Resource.NestriFamilyYearly.value, Resource.NestriFamilyMonthly.value]
|
||||
default:
|
||||
return [Resource.NestriFreeMonthly.value]
|
||||
}
|
||||
}
|
||||
// const getProductIDs = (plan: z.infer<typeof planType>) => {
|
||||
// switch (plan) {
|
||||
// case "free":
|
||||
// return [Resource.NestriFreeMonthly.value]
|
||||
// case "pro":
|
||||
// return [Resource.NestriProYearly.value, Resource.NestriProMonthly.value]
|
||||
// case "family":
|
||||
// return [Resource.NestriFamilyYearly.value, Resource.NestriFamilyMonthly.value]
|
||||
// default:
|
||||
// return [Resource.NestriFreeMonthly.value]
|
||||
// }
|
||||
// }
|
||||
|
||||
export const createPortal = fn(
|
||||
z.string(),
|
||||
@@ -53,44 +54,10 @@ export namespace Polar {
|
||||
)
|
||||
|
||||
//TODO: Implement this
|
||||
export const handleWebhook = async(payload: ReturnType<typeof validateEvent>) => {
|
||||
export const handleWebhook = async (payload: ReturnType<typeof validateEvent>) => {
|
||||
switch (payload.type) {
|
||||
case "subscription.created":
|
||||
const teamID = payload.data.metadata.teamID
|
||||
}
|
||||
}
|
||||
|
||||
export const createCheckout = fn(
|
||||
z
|
||||
.object({
|
||||
planType: z.enum(PlanType),
|
||||
customerEmail: z.string(),
|
||||
successUrl: z.string(),
|
||||
customerID: z.string(),
|
||||
allowDiscountCodes: z.boolean(),
|
||||
teamID: z.string()
|
||||
})
|
||||
.partial({
|
||||
customerEmail: true,
|
||||
allowDiscountCodes: true,
|
||||
customerID: true,
|
||||
teamID: true
|
||||
}),
|
||||
async (input) => {
|
||||
const productIDs = getProductIDs(input.planType)
|
||||
|
||||
const checkoutUrl =
|
||||
await client.checkouts.create({
|
||||
products: productIDs,
|
||||
customerEmail: input.customerEmail ?? useUserID(),
|
||||
successUrl: `${input.successUrl}?checkout={CHECKOUT_ID}`,
|
||||
allowDiscountCodes: input.allowDiscountCodes ?? false,
|
||||
customerId: input.customerID,
|
||||
customerMetadata: {
|
||||
teamID: input.teamID ?? useTeam()
|
||||
}
|
||||
})
|
||||
|
||||
return checkoutUrl.url
|
||||
})
|
||||
}
|
||||
@@ -2,14 +2,14 @@ import {
|
||||
IoTDataPlaneClient,
|
||||
PublishCommand,
|
||||
} from "@aws-sdk/client-iot-data-plane";
|
||||
import { useMachine } from "../actor";
|
||||
import { Actor } from "../actor";
|
||||
import { Resource } from "sst";
|
||||
|
||||
export namespace Realtime {
|
||||
const client = new IoTDataPlaneClient({});
|
||||
|
||||
export async function publish(message: any, subTopic?: string) {
|
||||
const fingerprint = useMachine();
|
||||
const fingerprint = Actor.assert("machine").properties.fingerprint;
|
||||
let topic = `${Resource.App.name}/${Resource.App.stage}/${fingerprint}/`;
|
||||
if (subTopic)
|
||||
topic = `${topic}${subTopic}`;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Actor } from "../actor";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import { createID, fn } from "../utils";
|
||||
import { useUser, useUserID } from "../actor";
|
||||
import { eq, and, isNull, sql } from "../drizzle";
|
||||
import { steamTable, AccountLimitation, LastGame } from "./steam.sql";
|
||||
import { createEvent } from "../event";
|
||||
import { eq, and, isNull, desc } from "drizzle-orm";
|
||||
import { steamTable, StatusEnum, Limitations } from "./steam.sql";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Steam {
|
||||
@@ -12,90 +13,196 @@ export namespace Steam {
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Steam.id,
|
||||
example: Examples.SteamAccount.id
|
||||
}),
|
||||
avatarUrl: z.string().openapi({
|
||||
description: "The avatar url of this Steam account",
|
||||
example: Examples.Steam.avatarUrl
|
||||
avatarHash: z.string().openapi({
|
||||
description: "The Steam avatar hash that this account owns",
|
||||
example: Examples.SteamAccount.avatarHash
|
||||
}),
|
||||
steamEmail: z.string().openapi({
|
||||
description: "The email regisered with this Steam account",
|
||||
example: Examples.Steam.steamEmail
|
||||
status: z.enum(StatusEnum.enumValues).openapi({
|
||||
description: "The current connection status of this Steam account",
|
||||
example: Examples.SteamAccount.status
|
||||
}),
|
||||
steamID: z.number().openapi({
|
||||
description: "The Steam ID this Steam account",
|
||||
example: Examples.Steam.steamID
|
||||
userID: z.string().nullable().openapi({
|
||||
description: "The user id of which account owns this steam account",
|
||||
example: Examples.SteamAccount.userID
|
||||
}),
|
||||
limitation: AccountLimitation.openapi({
|
||||
description: " The limitations of this Steam account",
|
||||
example: Examples.Steam.limitation
|
||||
profileUrl: z.string().nullable().openapi({
|
||||
description: "The steam community url of this account",
|
||||
example: Examples.SteamAccount.profileUrl
|
||||
}),
|
||||
lastGame: LastGame.openapi({
|
||||
description: "The last game played on this Steam account",
|
||||
example: Examples.Steam.lastGame
|
||||
realName: z.string().nullable().openapi({
|
||||
description: "The real name behind of this Steam account",
|
||||
example: Examples.SteamAccount.realName
|
||||
}),
|
||||
userID: z.string().openapi({
|
||||
description: "The unique id of the user who owns this steam account",
|
||||
example: Examples.Steam.userID
|
||||
name: z.string().openapi({
|
||||
description: "The name used by this account",
|
||||
example: Examples.SteamAccount.name
|
||||
}),
|
||||
username: z.string().openapi({
|
||||
description: "The unique username of this steam user",
|
||||
example: Examples.Steam.username
|
||||
lastSyncedAt: z.date().openapi({
|
||||
description: "The last time this account was synced to Steam",
|
||||
example: Examples.SteamAccount.lastSyncedAt
|
||||
}),
|
||||
personaName: z.string().openapi({
|
||||
description: "The last recorded persona name used by this account",
|
||||
example: Examples.Steam.personaName
|
||||
limitations: Limitations.openapi({
|
||||
description: "The limitations bestowed on this Steam account by Steam",
|
||||
example: Examples.SteamAccount.limitations
|
||||
}),
|
||||
countryCode: z.string().openapi({
|
||||
description: "The country this account is connected from",
|
||||
example: Examples.Steam.countryCode
|
||||
steamMemberSince: z.date().openapi({
|
||||
description: "When this Steam community account was created",
|
||||
example: Examples.SteamAccount.steamMemberSince
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Steam",
|
||||
description: "Represents a steam user's information stored on Nestri",
|
||||
example: Examples.Steam,
|
||||
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.partial({
|
||||
id: true,
|
||||
Info
|
||||
.extend({
|
||||
useUser: z.boolean(),
|
||||
})
|
||||
.partial({
|
||||
userID: true,
|
||||
status: true,
|
||||
useUser: true,
|
||||
lastSyncedAt: true
|
||||
}),
|
||||
(input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const id = input.id ?? createID("steam");
|
||||
const user = useUser()
|
||||
await tx.insert(steamTable).values({
|
||||
id,
|
||||
lastSeen: sql`now()`,
|
||||
userID: input.userID ?? user.userID,
|
||||
countryCode: input.countryCode,
|
||||
username: input.username,
|
||||
steamID: input.steamID,
|
||||
lastGame: input.lastGame,
|
||||
limitation: input.limitation,
|
||||
steamEmail: input.steamEmail,
|
||||
avatarUrl: input.avatarUrl,
|
||||
personaName: input.personaName,
|
||||
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(),
|
||||
})
|
||||
return id;
|
||||
|
||||
// await afterTx(async () =>
|
||||
// bus.publish(Resource.Bus, Events.Created, { userID, steamID: input.id })
|
||||
// );
|
||||
|
||||
return input.id
|
||||
}),
|
||||
);
|
||||
|
||||
export const updateOwner = fn(
|
||||
z
|
||||
.object({
|
||||
userID: z.string(),
|
||||
steamID: z.string()
|
||||
})
|
||||
.partial({
|
||||
userID: true
|
||||
}),
|
||||
async (input) =>
|
||||
createTransaction(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(),
|
||||
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).at(0)),
|
||||
),
|
||||
.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 = () =>
|
||||
@@ -103,34 +210,26 @@ export namespace Steam {
|
||||
tx
|
||||
.select()
|
||||
.from(steamTable)
|
||||
.where(and(eq(steamTable.userID, useUserID()), isNull(steamTable.timeDeleted)))
|
||||
.where(and(eq(steamTable.userID, Actor.userID()), isNull(steamTable.timeDeleted)))
|
||||
.orderBy(desc(steamTable.timeCreated))
|
||||
.execute()
|
||||
.then((rows) => rows.map(serialize)),
|
||||
.then((rows) => rows.map(serialize))
|
||||
)
|
||||
|
||||
/**
|
||||
* Serializes a raw Steam table record into a standardized Info object.
|
||||
*
|
||||
* This function maps the fields from a database record (retrieved from the Steam table) to the
|
||||
* corresponding properties defined in the Info schema.
|
||||
*
|
||||
* @param input - A raw record from the Steam table containing user information.
|
||||
* @returns An object conforming to the Info schema.
|
||||
*/
|
||||
export function serialize(
|
||||
input: typeof steamTable.$inferSelect,
|
||||
): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
status: input.status,
|
||||
userID: input.userID,
|
||||
countryCode: input.countryCode,
|
||||
username: input.username,
|
||||
avatarUrl: input.avatarUrl,
|
||||
personaName: input.personaName,
|
||||
steamEmail: input.steamEmail,
|
||||
steamID: input.steamID,
|
||||
limitation: input.limitation,
|
||||
lastGame: input.lastGame,
|
||||
realName: input.realName,
|
||||
profileUrl: input.profileUrl,
|
||||
avatarHash: input.avatarHash,
|
||||
limitations: input.limitations,
|
||||
lastSyncedAt: input.lastSyncedAt,
|
||||
steamMemberSince: input.steamMemberSince,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,45 +1,38 @@
|
||||
import { z } from "zod";
|
||||
import { userTable } from "../user/user.sql";
|
||||
import { id, timestamps, ulid, utc } from "../drizzle/types";
|
||||
import { index, pgTable, integer, uniqueIndex, varchar, text, json } from "drizzle-orm/pg-core";
|
||||
import { pgTable, varchar, pgEnum, json, unique } from "drizzle-orm/pg-core";
|
||||
|
||||
export const LastGame = z.object({
|
||||
gameID: z.number(),
|
||||
gameName: z.string()
|
||||
});
|
||||
export const StatusEnum = pgEnum("steam_status", ["online", "offline", "dnd", "playing"])
|
||||
|
||||
export const AccountLimitation = z.object({
|
||||
isLimited: z.boolean().nullable(),
|
||||
isBanned: z.boolean().nullable(),
|
||||
isLocked: z.boolean().nullable(),
|
||||
isAllowedToInviteFriends: z.boolean().nullable(),
|
||||
});
|
||||
export const Limitations = z.object({
|
||||
isLimited: z.boolean(),
|
||||
tradeBanState: z.enum(["none", "probation", "banned"]),
|
||||
isVacBanned: z.boolean(),
|
||||
visibilityState: z.number(),
|
||||
privacyState: z.enum(["public", "private", "friendsfriendsonly", "friendsonly"]),
|
||||
})
|
||||
|
||||
export type LastGame = z.infer<typeof LastGame>;
|
||||
export type AccountLimitation = z.infer<typeof AccountLimitation>;
|
||||
export type Limitations = z.infer<typeof Limitations>;
|
||||
|
||||
export const steamTable = pgTable(
|
||||
"steam",
|
||||
"steam_accounts",
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
id: varchar("id", { length: 255 })
|
||||
.primaryKey()
|
||||
.notNull(),
|
||||
userID: ulid("user_id")
|
||||
.notNull()
|
||||
.references(() => userTable.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
lastSeen: utc("last_seen").notNull(),
|
||||
steamID: integer("steam_id").notNull(),
|
||||
avatarUrl: text("avatar_url").notNull(),
|
||||
lastGame: json("last_game").$type<LastGame>().notNull(),
|
||||
username: varchar("username", { length: 255 }).notNull(),
|
||||
countryCode: varchar('country_code', { length: 2 }).notNull(),
|
||||
steamEmail: varchar("steam_email", { length: 255 }).notNull(),
|
||||
personaName: varchar("persona_name", { length: 255 }).notNull(),
|
||||
limitation: json("limitation").$type<AccountLimitation>().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("steam_id").on(table.steamID),
|
||||
index("steam_user_id").on(table.userID),
|
||||
],
|
||||
status: StatusEnum("status").notNull(),
|
||||
lastSyncedAt: utc("last_synced_at").notNull(),
|
||||
realName: varchar("real_name", { length: 255 }),
|
||||
steamMemberSince: utc("member_since").notNull(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
profileUrl: varchar("profile_url", { length: 255 }),
|
||||
avatarHash: varchar("avatar_hash", { length: 255 }).notNull(),
|
||||
limitations: json("limitations").$type<Limitations>().notNull(),
|
||||
}
|
||||
);
|
||||
@@ -1,192 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import { createID, fn } from "../utils";
|
||||
import { eq, and, isNull } from "../drizzle";
|
||||
import { useTeam, useUserID } from "../actor";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
import { PlanType, Standing, subscriptionTable } from "./subscription.sql";
|
||||
|
||||
export namespace Subscription {
|
||||
export const Info = z.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Subscription.id,
|
||||
}),
|
||||
polarSubscriptionID: z.string().nullable().or(z.undefined()).openapi({
|
||||
description: "The unique id of the plan this subscription is on",
|
||||
example: Examples.Subscription.polarSubscriptionID,
|
||||
}),
|
||||
teamID: z.string().openapi({
|
||||
description: "The unique id of the team this subscription is for",
|
||||
example: Examples.Subscription.teamID,
|
||||
}),
|
||||
userID: z.string().openapi({
|
||||
description: "The unique id of the user who is paying this subscription",
|
||||
example: Examples.Subscription.userID,
|
||||
}),
|
||||
polarProductID: z.string().nullable().or(z.undefined()).openapi({
|
||||
description: "The unique id of the product this subscription is for",
|
||||
example: Examples.Subscription.polarProductID,
|
||||
}),
|
||||
tokens: z.number().openapi({
|
||||
description: "The number of tokens this subscription has left",
|
||||
example: Examples.Subscription.tokens,
|
||||
}),
|
||||
planType: z.enum(PlanType).openapi({
|
||||
description: "The type of plan this subscription is for",
|
||||
example: Examples.Subscription.planType,
|
||||
}),
|
||||
standing: z.enum(Standing).openapi({
|
||||
description: "The standing of this subscription",
|
||||
example: Examples.Subscription.standing,
|
||||
}),
|
||||
}).openapi({
|
||||
ref: "Subscription",
|
||||
description: "Represents a subscription on Nestri",
|
||||
example: Examples.Subscription
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const create = fn(
|
||||
Info
|
||||
.partial({
|
||||
teamID: true,
|
||||
userID: true,
|
||||
id: true,
|
||||
standing: true,
|
||||
planType: true,
|
||||
polarProductID: true,
|
||||
polarSubscriptionID: true,
|
||||
}),
|
||||
(input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const id = input.id ?? createID("subscription");
|
||||
|
||||
await tx.insert(subscriptionTable).values({
|
||||
id,
|
||||
tokens: input.tokens,
|
||||
polarProductID: input.polarProductID ?? null,
|
||||
polarSubscriptionID: input.polarSubscriptionID ?? null,
|
||||
standing: input.standing ?? "new",
|
||||
planType: input.planType ?? "free",
|
||||
userID: input.userID ?? useUserID(),
|
||||
teamID: input.teamID ?? useTeam(),
|
||||
});
|
||||
|
||||
return id;
|
||||
})
|
||||
)
|
||||
|
||||
export const setPolarProductID = fn(
|
||||
Info.pick({
|
||||
id: true,
|
||||
polarProductID: true,
|
||||
}),
|
||||
(input) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx.update(subscriptionTable)
|
||||
.set({
|
||||
polarProductID: input.polarProductID,
|
||||
})
|
||||
.where(eq(subscriptionTable.id, input.id))
|
||||
)
|
||||
)
|
||||
|
||||
export const setPolarSubscriptionID = fn(
|
||||
Info.pick({
|
||||
id: true,
|
||||
polarSubscriptionID: true,
|
||||
}),
|
||||
(input) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx.update(subscriptionTable)
|
||||
.set({
|
||||
polarSubscriptionID: input.polarSubscriptionID,
|
||||
})
|
||||
.where(eq(subscriptionTable.id, input.id))
|
||||
)
|
||||
)
|
||||
|
||||
export const fromID = fn(z.string(), async (id) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(subscriptionTable)
|
||||
.where(
|
||||
and(
|
||||
eq(subscriptionTable.id, id),
|
||||
isNull(subscriptionTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.orderBy(subscriptionTable.timeCreated)
|
||||
.then((rows) => rows.map(serialize))
|
||||
)
|
||||
)
|
||||
export const fromTeamID = fn(z.string(), async (teamID) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(subscriptionTable)
|
||||
.where(
|
||||
and(
|
||||
eq(subscriptionTable.teamID, teamID),
|
||||
isNull(subscriptionTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.orderBy(subscriptionTable.timeCreated)
|
||||
.then((rows) => rows.map(serialize))
|
||||
)
|
||||
)
|
||||
|
||||
export const fromUserID = fn(z.string(), async (userID) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(subscriptionTable)
|
||||
.where(
|
||||
and(
|
||||
eq(subscriptionTable.userID, userID),
|
||||
isNull(subscriptionTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.orderBy(subscriptionTable.timeCreated)
|
||||
.then((rows) => rows.map(serialize))
|
||||
)
|
||||
)
|
||||
export const remove = fn(Info.shape.id, (id) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.update(subscriptionTable)
|
||||
.set({
|
||||
timeDeleted: Common.now(),
|
||||
})
|
||||
.where(eq(subscriptionTable.id, id))
|
||||
.execute()
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* Converts a raw subscription database record into a structured {@link Info} object.
|
||||
*
|
||||
* @param input - The subscription record retrieved from the database.
|
||||
* @returns The subscription data formatted according to the {@link Info} schema.
|
||||
*/
|
||||
export function serialize(
|
||||
input: typeof subscriptionTable.$inferSelect
|
||||
): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
userID: input.userID,
|
||||
teamID: input.teamID,
|
||||
standing: input.standing,
|
||||
planType: input.planType,
|
||||
tokens: input.tokens,
|
||||
polarProductID: input.polarProductID,
|
||||
polarSubscriptionID: input.polarSubscriptionID,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { teamTable } from "../team/team.sql";
|
||||
import { ulid, userID, timestamps } from "../drizzle/types";
|
||||
import { index, integer, pgTable, primaryKey, text, uniqueIndex, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const Standing = ["new", "good", "overdue", "cancelled"] as const;
|
||||
export const PlanType = ["free", "pro", "family", "enterprise"] as const;
|
||||
|
||||
export const subscriptionTable = pgTable(
|
||||
"subscription",
|
||||
{
|
||||
...userID,
|
||||
...timestamps,
|
||||
teamID: ulid("team_id")
|
||||
.references(() => teamTable.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
standing: text("standing", { enum: Standing })
|
||||
.notNull(),
|
||||
planType: text("plan_type", { enum: PlanType })
|
||||
.notNull(),
|
||||
tokens: integer("tokens").notNull(),
|
||||
polarProductID: varchar("product_id", { length: 255 }),
|
||||
polarSubscriptionID: varchar("subscription_id", { length: 255 }),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("subscription_id").on(table.id),
|
||||
index("subscription_user_id").on(table.userID),
|
||||
primaryKey({
|
||||
columns: [table.id, table.teamID]
|
||||
}),
|
||||
]
|
||||
)
|
||||
@@ -1,20 +0,0 @@
|
||||
import { id, timestamps } from "../drizzle/types";
|
||||
import { pgTable, uniqueIndex, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
//This represents a task created on a machine for running a game
|
||||
//Add billing info here?
|
||||
//Add who owns the task here
|
||||
// Add the session ID here
|
||||
//Add which machine owns this task
|
||||
|
||||
export const taskTable = pgTable(
|
||||
"task",
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
fingerprint: varchar('fingerprint', { length: 32 }).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("task_fingerprint").on(table.fingerprint),
|
||||
],
|
||||
);
|
||||
@@ -1,218 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { Common } from "../common";
|
||||
import { Member } from "../member";
|
||||
import { teamTable } from "./team.sql";
|
||||
import { Examples } from "../examples";
|
||||
import { assertActor } from "../actor";
|
||||
import { createEvent } from "../event";
|
||||
import { createID, fn } from "../utils";
|
||||
import { Subscription } from "../subscription";
|
||||
import { and, eq, sql, isNull } from "../drizzle";
|
||||
import { memberTable } from "../member/member.sql";
|
||||
import { ErrorCodes, VisibleError } from "../error";
|
||||
import { groupBy, map, pipe, values } from "remeda";
|
||||
import { subscriptionTable } from "../subscription/subscription.sql";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Team {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Team.id,
|
||||
}),
|
||||
// Remove spaces and make sure it is lowercase (this is just to make sure the frontend did this)
|
||||
slug: z.string().regex(/^[a-z0-9\-]+$/, "Use a URL friendly name.").openapi({
|
||||
description: "The unique and url-friendly slug of this team",
|
||||
example: Examples.Team.slug
|
||||
}),
|
||||
name: z.string().openapi({
|
||||
description: "The name of this team",
|
||||
example: Examples.Team.name
|
||||
}),
|
||||
members: Member.Info.array().openapi({
|
||||
description: "The members of this team",
|
||||
example: Examples.Team.members
|
||||
}),
|
||||
subscriptions: Subscription.Info.array().openapi({
|
||||
description: "The subscriptions of this team",
|
||||
example: Examples.Team.subscriptions
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Team",
|
||||
description: "Represents a team on Nestri",
|
||||
example: Examples.Team,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const Events = {
|
||||
Created: createEvent(
|
||||
"team.created",
|
||||
z.object({
|
||||
teamID: z.string().nonempty(),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
export class TeamExistsError extends VisibleError {
|
||||
constructor(slug: string) {
|
||||
super(
|
||||
"already_exists",
|
||||
ErrorCodes.Validation.TEAM_ALREADY_EXISTS,
|
||||
`There is already a team named "${slug}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const create = fn(
|
||||
Info.pick({ slug: true, id: true, name: true, }).partial({
|
||||
id: true,
|
||||
}), (input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const id = input.id ?? createID("team");
|
||||
const result = await tx.insert(teamTable).values({
|
||||
id,
|
||||
slug: input.slug,
|
||||
name: input.name
|
||||
})
|
||||
.onConflictDoNothing({ target: teamTable.slug })
|
||||
|
||||
if (result.count === 0) throw new TeamExistsError(input.slug);
|
||||
|
||||
return id;
|
||||
})
|
||||
)
|
||||
|
||||
//TODO: "Delete" subscription and member(s) as well
|
||||
export const remove = fn(Info.shape.id, (input) =>
|
||||
useTransaction(async (tx) => {
|
||||
const account = assertActor("user");
|
||||
const row = await tx
|
||||
.select({
|
||||
teamID: memberTable.teamID,
|
||||
})
|
||||
.from(memberTable)
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.teamID, input),
|
||||
eq(memberTable.email, account.properties.email),
|
||||
),
|
||||
)
|
||||
.execute()
|
||||
.then((rows) => rows.at(0));
|
||||
if (!row) return;
|
||||
await tx
|
||||
.update(teamTable)
|
||||
.set({
|
||||
timeDeleted: sql`now()`,
|
||||
})
|
||||
.where(eq(teamTable.id, row.teamID));
|
||||
}),
|
||||
);
|
||||
|
||||
export const list = fn(z.void(), () => {
|
||||
const actor = assertActor("user");
|
||||
return useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(teamTable)
|
||||
.leftJoin(subscriptionTable, eq(subscriptionTable.teamID, teamTable.id))
|
||||
.innerJoin(memberTable, eq(memberTable.teamID, teamTable.id))
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.email, actor.properties.email),
|
||||
isNull(memberTable.timeDeleted),
|
||||
isNull(teamTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.execute()
|
||||
.then((rows) => serialize(rows))
|
||||
)
|
||||
});
|
||||
|
||||
export const fromID = fn(z.string().min(1), async (id) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(teamTable)
|
||||
.leftJoin(subscriptionTable, eq(subscriptionTable.teamID, teamTable.id))
|
||||
.innerJoin(memberTable, eq(memberTable.teamID, teamTable.id))
|
||||
.where(
|
||||
and(
|
||||
eq(teamTable.id, id),
|
||||
isNull(memberTable.timeDeleted),
|
||||
isNull(teamTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.execute()
|
||||
.then((rows) => serialize(rows).at(0))
|
||||
),
|
||||
);
|
||||
|
||||
export const fromSlug = fn(z.string().min(1), async (slug) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(teamTable)
|
||||
.leftJoin(subscriptionTable, eq(subscriptionTable.teamID, teamTable.id))
|
||||
.innerJoin(memberTable, eq(memberTable.teamID, teamTable.id))
|
||||
.where(
|
||||
and(
|
||||
eq(teamTable.slug, slug),
|
||||
isNull(memberTable.timeDeleted),
|
||||
isNull(teamTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.execute()
|
||||
.then((rows) => serialize(rows).at(0))
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Transforms an array of team, subscription, and member records into structured team objects.
|
||||
*
|
||||
* Groups input rows by team ID and constructs an array of team objects, each including its associated members and subscriptions.
|
||||
*
|
||||
* @param input - Array of objects containing team, subscription, and member data.
|
||||
* @returns An array of team objects with their members and subscriptions.
|
||||
*/
|
||||
export function serialize(
|
||||
input: { team: typeof teamTable.$inferSelect, subscription: typeof subscriptionTable.$inferInsert | null, member: typeof memberTable.$inferInsert | null }[],
|
||||
): z.infer<typeof Info>[] {
|
||||
console.log("serialize", input)
|
||||
return pipe(
|
||||
input,
|
||||
groupBy((row) => row.team.id),
|
||||
values(),
|
||||
map((group) => ({
|
||||
name: group[0].team.name,
|
||||
id: group[0].team.id,
|
||||
slug: group[0].team.slug,
|
||||
subscriptions: !group[0].subscription ?
|
||||
[] :
|
||||
group.map((row) => ({
|
||||
planType: row.subscription!.planType,
|
||||
polarProductID: row.subscription!.polarProductID,
|
||||
polarSubscriptionID: row.subscription!.polarSubscriptionID,
|
||||
standing: row.subscription!.standing,
|
||||
tokens: row.subscription!.tokens,
|
||||
teamID: row.subscription!.teamID,
|
||||
userID: row.subscription!.userID,
|
||||
id: row.subscription!.id,
|
||||
})),
|
||||
members:
|
||||
!group[0].member ?
|
||||
[] :
|
||||
group.map((row) => ({
|
||||
id: row.member!.id,
|
||||
email: row.member!.email,
|
||||
role: row.member!.role,
|
||||
teamID: row.member!.teamID,
|
||||
timeSeen: row.member!.timeSeen,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { timestamps, id } from "../drizzle/types";
|
||||
import {
|
||||
varchar,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
export const teamTable = pgTable(
|
||||
"team",
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
slug: varchar("slug", { length: 255 }).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("slug").on(table.slug)
|
||||
],
|
||||
);
|
||||
|
||||
export function teamIndexes(table: any) {
|
||||
return [
|
||||
primaryKey({
|
||||
columns: [table.teamID, table.id],
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -1,66 +1,60 @@
|
||||
import { z } from "zod";
|
||||
import { Team } from "../team";
|
||||
import { bus } from "sst/aws/bus";
|
||||
import { Steam } from "../steam";
|
||||
import { Common } from "../common";
|
||||
import { createEvent } from "../event";
|
||||
import { Polar } from "../polar/index";
|
||||
import { createID, fn } from "../utils";
|
||||
import { userTable } from "./user.sql";
|
||||
import { createEvent } from "../event";
|
||||
import { Examples } from "../examples";
|
||||
import { Resource } from "sst/resource";
|
||||
import { teamTable } from "../team/team.sql";
|
||||
import { steamTable } from "../steam/steam.sql";
|
||||
import { assertActor, withActor } from "../actor";
|
||||
import { memberTable } from "../member/member.sql";
|
||||
import { pipe, groupBy, values, map } from "remeda";
|
||||
import { and, eq, isNull, asc, sql } from "../drizzle";
|
||||
import { subscriptionTable } from "../subscription/subscription.sql";
|
||||
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
import { and, eq, isNull, asc } from "drizzle-orm";
|
||||
import { ErrorCodes, VisibleError } from "../error";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace User {
|
||||
const MAX_ATTEMPTS = 50;
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.User.id,
|
||||
}),
|
||||
name: z.string().openapi({
|
||||
description: "The user's unique username",
|
||||
example: Examples.User.name,
|
||||
name: z.string().regex(/^[a-zA-Z ]{1,32}$/, "Use a friendly name.").openapi({
|
||||
description: "The name of this account",
|
||||
example: Examples.User.name
|
||||
}),
|
||||
polarCustomerID: z.string().or(z.null()).openapi({
|
||||
description: "The polar customer id for this user",
|
||||
polarCustomerID: z.string().nullable().openapi({
|
||||
description: "Associated Polar.sh customer identifier",
|
||||
example: Examples.User.polarCustomerID,
|
||||
}),
|
||||
avatarUrl: z.string().url().nullable().openapi({
|
||||
description: "The url to the profile picture",
|
||||
example: Examples.User.avatarUrl
|
||||
}),
|
||||
email: z.string().openapi({
|
||||
description: "The email address of this user",
|
||||
description: "Primary email address for user notifications and authentication",
|
||||
example: Examples.User.email,
|
||||
}),
|
||||
avatarUrl: z.string().or(z.null()).openapi({
|
||||
description: "The url to the profile picture.",
|
||||
example: Examples.User.name,
|
||||
}),
|
||||
discriminator: z.string().or(z.number()).openapi({
|
||||
description: "The (number) discriminator for this user",
|
||||
example: Examples.User.discriminator,
|
||||
}),
|
||||
steamAccounts: Steam.Info.array().openapi({
|
||||
description: "The steam accounts for this user",
|
||||
example: Examples.User.steamAccounts,
|
||||
}),
|
||||
lastLogin: z.date().openapi({
|
||||
description: "Timestamp of user's most recent authentication",
|
||||
example: Examples.User.lastLogin
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "User",
|
||||
description: "Represents a user on Nestri",
|
||||
description: "User account entity with core identification and authentication details",
|
||||
example: Examples.User,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export class UserExistsError extends VisibleError {
|
||||
constructor(username: string) {
|
||||
super(
|
||||
"already_exists",
|
||||
ErrorCodes.Validation.ALREADY_EXISTS,
|
||||
`A user with this email ${username} already exists`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const Events = {
|
||||
Created: createEvent(
|
||||
"user.created",
|
||||
@@ -68,122 +62,93 @@ export namespace User {
|
||||
userID: Info.shape.id,
|
||||
}),
|
||||
),
|
||||
Updated: createEvent(
|
||||
"user.updated",
|
||||
z.object({
|
||||
userID: Info.shape.id,
|
||||
};
|
||||
|
||||
export const create = fn(
|
||||
Info
|
||||
.omit({
|
||||
lastLogin: true,
|
||||
polarCustomerID: true,
|
||||
}).partial({
|
||||
avatarUrl: true,
|
||||
id: true
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
export const sanitizeUsername = (username: string): string => {
|
||||
// Remove spaces and numbers
|
||||
return username.replace(/[\s0-9]/g, '');
|
||||
};
|
||||
|
||||
export const generateDiscriminator = (): string => {
|
||||
return Math.floor(Math.random() * 100).toString().padStart(2, '0');
|
||||
};
|
||||
|
||||
export const isValidDiscriminator = (discriminator: string): boolean => {
|
||||
return /^\d{2}$/.test(discriminator);
|
||||
};
|
||||
|
||||
export const findAvailableDiscriminator = fn(z.string(), async (input) => {
|
||||
const username = sanitizeUsername(input);
|
||||
|
||||
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
||||
const discriminator = generateDiscriminator();
|
||||
|
||||
const users = await useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(userTable)
|
||||
.where(and(eq(userTable.name, username), eq(userTable.discriminator, Number(discriminator))))
|
||||
)
|
||||
|
||||
if (users.length === 0) {
|
||||
return discriminator;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
|
||||
export const create = fn(Info.omit({ polarCustomerID: true, discriminator: true, steamAccounts: true }).partial({ avatarUrl: true, id: true }), async (input) => {
|
||||
async (input) => {
|
||||
const userID = createID("user")
|
||||
|
||||
//FIXME: Do this much later, as Polar.sh has so many inconsistencies for fuck's sake
|
||||
|
||||
const customer = await Polar.fromUserEmail(input.email)
|
||||
console.log("customer", customer)
|
||||
|
||||
const name = sanitizeUsername(input.name);
|
||||
|
||||
// Generate a random available discriminator
|
||||
const discriminator = await findAvailableDiscriminator(name);
|
||||
|
||||
if (!discriminator) {
|
||||
console.error("No available discriminators for this username ")
|
||||
return null
|
||||
}
|
||||
|
||||
createTransaction(async (tx) => {
|
||||
const id = input.id ?? userID;
|
||||
await tx.insert(userTable).values({
|
||||
|
||||
await createTransaction(async (tx) => {
|
||||
const result = await tx
|
||||
.insert(userTable)
|
||||
.values({
|
||||
id,
|
||||
name: input.name,
|
||||
avatarUrl: input.avatarUrl,
|
||||
email: input.email,
|
||||
discriminator: Number(discriminator),
|
||||
polarCustomerID: customer?.id
|
||||
name: input.name,
|
||||
polarCustomerID: customer?.id,
|
||||
lastLogin: Common.utc()
|
||||
})
|
||||
await afterTx(() =>
|
||||
withActor({
|
||||
type: "user",
|
||||
properties: {
|
||||
userID: id,
|
||||
email: input.email
|
||||
},
|
||||
},
|
||||
async () => bus.publish(Resource.Bus, Events.Created, { userID: id }),
|
||||
)
|
||||
);
|
||||
.onConflictDoNothing({
|
||||
target: [userTable.email]
|
||||
})
|
||||
|
||||
return userID;
|
||||
if (result.count === 0) {
|
||||
throw new UserExistsError(input.email)
|
||||
}
|
||||
})
|
||||
|
||||
export const fromEmail = fn(z.string(), async (email) =>
|
||||
return id;
|
||||
})
|
||||
|
||||
export const fromEmail = fn(
|
||||
Info.shape.email.min(1),
|
||||
async (email) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(userTable)
|
||||
.leftJoin(steamTable, eq(userTable.id, steamTable.userID))
|
||||
.where(and(eq(userTable.email, email), isNull(userTable.timeDeleted)))
|
||||
.where(
|
||||
and(
|
||||
eq(userTable.email, email),
|
||||
isNull(userTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(userTable.timeCreated))
|
||||
.then((rows => serialize(rows).at(0)))
|
||||
.execute()
|
||||
.then(rows => rows.map(serialize).at(0))
|
||||
)
|
||||
)
|
||||
|
||||
export const fromID = fn(z.string(), (id) =>
|
||||
export const fromID = fn(
|
||||
Info.shape.id.min(1),
|
||||
(id) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(userTable)
|
||||
.leftJoin(steamTable, eq(userTable.id, steamTable.userID))
|
||||
.where(and(eq(userTable.id, id), isNull(userTable.timeDeleted), isNull(steamTable.timeDeleted)))
|
||||
.where(
|
||||
and(
|
||||
eq(userTable.id, id),
|
||||
isNull(userTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(userTable.timeCreated))
|
||||
.then((rows) => serialize(rows).at(0))
|
||||
.execute()
|
||||
.then(rows => rows.map(serialize).at(0))
|
||||
),
|
||||
)
|
||||
|
||||
export const remove = fn(Info.shape.id, (id) =>
|
||||
export const remove = fn(
|
||||
Info.shape.id.min(1),
|
||||
(id) =>
|
||||
useTransaction(async (tx) => {
|
||||
await tx
|
||||
.update(userTable)
|
||||
.set({
|
||||
timeDeleted: sql`now()`,
|
||||
timeDeleted: Common.utc(),
|
||||
})
|
||||
.where(and(eq(userTable.id, id)))
|
||||
.execute();
|
||||
@@ -191,64 +156,31 @@ export namespace User {
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Converts an array of user and Steam account records into structured user objects with associated Steam accounts.
|
||||
*
|
||||
* @param input - An array of objects containing user data and optional Steam account data.
|
||||
* @returns An array of user objects, each including a list of their associated Steam accounts.
|
||||
*/
|
||||
export function serialize(
|
||||
input: { user: typeof userTable.$inferSelect; steam: typeof steamTable.$inferSelect | null }[],
|
||||
): z.infer<typeof Info>[] {
|
||||
return pipe(
|
||||
input,
|
||||
groupBy((row) => row.user.id),
|
||||
values(),
|
||||
map((group) => ({
|
||||
...group[0].user,
|
||||
steamAccounts: !group[0].steam ?
|
||||
[] :
|
||||
group.map((row) => ({
|
||||
id: row.steam!.id,
|
||||
lastSeen: row.steam!.lastSeen,
|
||||
countryCode: row.steam!.countryCode,
|
||||
username: row.steam!.username,
|
||||
steamID: row.steam!.steamID,
|
||||
lastGame: row.steam!.lastGame,
|
||||
limitation: row.steam!.limitation,
|
||||
steamEmail: row.steam!.steamEmail,
|
||||
userID: row.steam!.userID,
|
||||
personaName: row.steam!.personaName,
|
||||
avatarUrl: row.steam!.avatarUrl,
|
||||
})),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the list of teams that the current user belongs to.
|
||||
*
|
||||
* @returns An array of team information objects representing the user's active team memberships.
|
||||
*
|
||||
* @remark Only teams and memberships that have not been deleted are included in the result.
|
||||
*/
|
||||
export function teams() {
|
||||
const actor = assertActor("user");
|
||||
return useTransaction(async (tx) =>
|
||||
export const acknowledgeLogin = fn(
|
||||
Info.shape.id,
|
||||
(id) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(teamTable)
|
||||
.leftJoin(subscriptionTable, eq(subscriptionTable.teamID, teamTable.id))
|
||||
.innerJoin(memberTable, eq(memberTable.teamID, teamTable.id))
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.email, actor.properties.email),
|
||||
isNull(memberTable.timeDeleted),
|
||||
isNull(teamTable.timeDeleted),
|
||||
.update(userTable)
|
||||
.set({
|
||||
lastLogin: Common.utc(),
|
||||
})
|
||||
.where(and(eq(userTable.id, id)))
|
||||
.execute()
|
||||
|
||||
),
|
||||
)
|
||||
.execute()
|
||||
.then((rows) => Team.serialize(rows))
|
||||
)
|
||||
|
||||
export function serialize(
|
||||
input: typeof userTable.$inferSelect
|
||||
): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
email: input.email,
|
||||
avatarUrl: input.avatarUrl,
|
||||
lastLogin: input.lastLogin,
|
||||
polarCustomerID: input.polarCustomerID,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,18 @@
|
||||
import { z } from "zod";
|
||||
import { id, timestamps } from "../drizzle/types";
|
||||
import { integer, pgTable, text, uniqueIndex, varchar, json } from "drizzle-orm/pg-core";
|
||||
|
||||
// Whether this user is part of the Nestri Team, comes with privileges
|
||||
export const UserFlags = z.object({
|
||||
team: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type UserFlags = z.infer<typeof UserFlags>;
|
||||
import { id, timestamps, utc } from "../drizzle/types";
|
||||
import { pgTable, text, unique, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const userTable = pgTable(
|
||||
"user",
|
||||
"users",
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
avatarUrl: text("avatar_url"),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
discriminator: integer("discriminator").notNull(),
|
||||
email: varchar("email", { length: 255 }).notNull(),
|
||||
polarCustomerID: varchar("polar_customer_id", { length: 255 }).unique(),
|
||||
// flags: json("flags").$type<UserFlags>().default({}),
|
||||
avatarUrl: text("avatar_url"),
|
||||
lastLogin: utc("last_login").notNull(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
polarCustomerID: varchar("polar_customer_id", { length: 255 }),
|
||||
},
|
||||
(user) => [
|
||||
uniqueIndex("user_email").on(user.email),
|
||||
unique("idx_user_email").on(user.email),
|
||||
]
|
||||
);
|
||||
10
packages/core/src/utils/helper.ts
Normal file
10
packages/core/src/utils/helper.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export function chunkArray<T>(arr: T[], chunkSize: number): T[][] {
|
||||
if (chunkSize <= 0) {
|
||||
throw new Error("chunkSize must be a positive integer");
|
||||
}
|
||||
const chunks: T[][] = [];
|
||||
for (let i = 0; i < arr.length; i += chunkSize) {
|
||||
chunks.push(arr.slice(i, i + chunkSize));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
@@ -2,14 +2,20 @@ import { ulid } from "ulid";
|
||||
|
||||
export const prefixes = {
|
||||
user: "usr",
|
||||
credentials:"crd",
|
||||
team: "tem",
|
||||
task: "tsk",
|
||||
product: "prd",
|
||||
session: "ses",
|
||||
machine: "mch",
|
||||
member: "mbr",
|
||||
steam: "stm",
|
||||
variant: "var",
|
||||
gpu: "gpu",
|
||||
game: "gme",
|
||||
usage: "usg",
|
||||
subscription: "sub",
|
||||
invite: "inv",
|
||||
product: "prd",
|
||||
// task: "tsk",
|
||||
// invite: "inv",
|
||||
// product: "prd",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
export * from "./fn"
|
||||
export * from "./id"
|
||||
export * from "./fn"
|
||||
export * from "./log"
|
||||
export * from "./invite"
|
||||
export * from "./helper"
|
||||
32
packages/core/src/utils/invite.ts
Normal file
32
packages/core/src/utils/invite.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export namespace Invite {
|
||||
/**
|
||||
* Generates a random invite code for teams
|
||||
* @param length The length of the invite code (default: 8)
|
||||
* @returns A string containing alphanumeric characters (excluding confusing characters)
|
||||
*/
|
||||
export function generateCode(length: number = 8): string {
|
||||
// Use only unambiguous characters (no 0/O, 1/l/I confusion)
|
||||
const characters = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
let result = '';
|
||||
|
||||
// Create a Uint32Array of the required length for randomness
|
||||
const randomValues = new Uint32Array(length);
|
||||
|
||||
// Fill with cryptographically strong random values if available
|
||||
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
||||
crypto.getRandomValues(randomValues);
|
||||
} else {
|
||||
// Fallback for environments without crypto
|
||||
for (let i = 0; i < length; i++) {
|
||||
randomValues[i] = Math.floor(Math.random() * 2 ** 32);
|
||||
}
|
||||
}
|
||||
|
||||
// Use the random values to select characters
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += characters.charAt(randomValues[i] % characters.length);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
76
packages/core/src/utils/log.ts
Normal file
76
packages/core/src/utils/log.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { createContext } from "../context";
|
||||
|
||||
export namespace Log {
|
||||
const ctx = createContext<{
|
||||
tags: Record<string, any>;
|
||||
}>();
|
||||
|
||||
export function create(tags?: Record<string, any>) {
|
||||
tags = tags || {};
|
||||
|
||||
const result = {
|
||||
info(msg: string, extra?: Record<string, any>) {
|
||||
const prefix = Object.entries({
|
||||
...use().tags,
|
||||
...tags,
|
||||
...extra,
|
||||
})
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(" ");
|
||||
console.log(prefix, msg);
|
||||
return result;
|
||||
},
|
||||
warn(msg: string, extra?: Record<string, any>) {
|
||||
const prefix = Object.entries({
|
||||
...use().tags,
|
||||
...tags,
|
||||
...extra,
|
||||
})
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(" ");
|
||||
console.warn(prefix, msg);
|
||||
return result;
|
||||
},
|
||||
error(error: Error) {
|
||||
const prefix = Object.entries({
|
||||
...use().tags,
|
||||
...tags,
|
||||
})
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(" ");
|
||||
console.error(prefix, error);
|
||||
return result;
|
||||
},
|
||||
tag(key: string, value: string) {
|
||||
// Immutable update: return a fresh logger with updated tags
|
||||
return Log.create({ ...tags, [key]: value });
|
||||
},
|
||||
clone() {
|
||||
return Log.create({ ...tags });
|
||||
},
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function provide<R>(tags: Record<string, any>, cb: () => R) {
|
||||
const existing = use();
|
||||
return ctx.provide(
|
||||
{
|
||||
tags: {
|
||||
...existing.tags,
|
||||
...tags,
|
||||
},
|
||||
},
|
||||
cb,
|
||||
);
|
||||
}
|
||||
|
||||
function use() {
|
||||
try {
|
||||
return ctx.use();
|
||||
} catch (e) {
|
||||
return { tags: {} };
|
||||
}
|
||||
}
|
||||
}
|
||||
171
packages/functions/.gitignore
vendored
171
packages/functions/.gitignore
vendored
@@ -1,172 +1,31 @@
|
||||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
|
||||
logs
|
||||
_.log
|
||||
npm-debug.log_
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Caches
|
||||
|
||||
.cache
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# Runtime data
|
||||
|
||||
pids
|
||||
_.pid
|
||||
_.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
|
||||
.yarn-integrity
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
|
||||
.temp
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# auth
|
||||
# @nestri/functions
|
||||
|
||||
To install dependencies:
|
||||
|
||||
@@ -12,4 +12,4 @@ To run:
|
||||
bun run index.ts
|
||||
```
|
||||
|
||||
This project was created using `bun init` in bun v1.1.34. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
|
||||
This project was created using `bun init` in bun v1.2.11. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
|
||||
|
||||
@@ -1,31 +1,32 @@
|
||||
{
|
||||
"name": "@nestri/functions",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/steamcommunity": "^3.43.8"
|
||||
},
|
||||
"scripts": {
|
||||
"dev:auth": "bun run --watch ./src/auth/index.ts",
|
||||
"dev:api": "bun run --watch ./src/api/index.ts"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"exports": {
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"dev:auth": "bun run --watch ./src/auth.ts",
|
||||
"dev:api": "bun run --watch ./src/api/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aws-sdk/client-ecs": "^3.738.0",
|
||||
"@aws-sdk/client-sqs": "^3.734.0",
|
||||
"@cloudflare/workers-types": "^4.20241224.0",
|
||||
"@nestri/core": "*",
|
||||
"@types/bun": "latest",
|
||||
"valibot": "^1.0.0-beta.9"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actor-core/bun": "^0.7.9",
|
||||
"@openauthjs/openauth": "*",
|
||||
"actor-core": "^0.7.9",
|
||||
"hono": "^4.6.15",
|
||||
"hono-openapi": "^0.3.1",
|
||||
"partysocket": "1.0.3",
|
||||
"postgres": "^3.4.5"
|
||||
"@actor-core/bun": "^0.8.0",
|
||||
"@actor-core/file-system": "^0.8.0",
|
||||
"@aws-sdk/client-lambda": "^3.821.0",
|
||||
"@aws-sdk/client-s3": "^3.806.0",
|
||||
"@aws-sdk/client-sqs": "^3.806.0",
|
||||
"@nestri/core": "workspace:",
|
||||
"actor-core": "^0.8.0",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"hono": "^4.7.8",
|
||||
"hono-openapi": "^0.4.8"
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,9 @@
|
||||
import { z } from "zod";
|
||||
import { Hono } from "hono";
|
||||
import { notPublic } from "./auth";
|
||||
import { notPublic } from "./utils";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { User } from "@nestri/core/user/index";
|
||||
import { Team } from "@nestri/core/team/index";
|
||||
import { assertActor } from "@nestri/core/actor";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { ErrorResponses, Result } from "./common";
|
||||
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
import { ErrorResponses, Result } from "./utils";
|
||||
import { Account } from "@nestri/core/account/index";
|
||||
|
||||
export namespace AccountApi {
|
||||
export const route = new Hono()
|
||||
@@ -22,39 +18,23 @@ export namespace AccountApi {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
z.object({
|
||||
...User.Info.shape,
|
||||
teams: Team.Info.array(),
|
||||
}).openapi({
|
||||
Account.Info.openapi({
|
||||
description: "User account information",
|
||||
example: { ...Examples.User, teams: [Examples.Team] }
|
||||
example: { ...Examples.User, profiles: [Examples.SteamAccount] }
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "User account details"
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
404: ErrorResponses[404],
|
||||
429: ErrorResponses[429]
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
async (c) => {
|
||||
const actor = assertActor("user");
|
||||
const [currentUser, teams] = await Promise.all([User.fromID(actor.properties.userID), User.teams()])
|
||||
|
||||
if (!currentUser)
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
"User not found",
|
||||
);
|
||||
|
||||
return c.json({
|
||||
data: {
|
||||
...currentUser,
|
||||
teams,
|
||||
}
|
||||
}, 200);
|
||||
},
|
||||
async (c) =>
|
||||
c.json({
|
||||
data: await Account.list()
|
||||
}, 200)
|
||||
)
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
import { z, ZodSchema } from "zod";
|
||||
import {type Hook } from "./types/hook";
|
||||
import { ErrorCodes, ErrorResponse } from "@nestri/core/error";
|
||||
import type { MiddlewareHandler, ValidationTargets } from "hono";
|
||||
import { resolver, validator as zodValidator } from "hono-openapi/zod";
|
||||
|
||||
export function Result<T extends z.ZodTypeAny>(schema: T) {
|
||||
return resolver(
|
||||
z.object({
|
||||
data: schema,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom validator wrapper around hono-openapi/zod validator that formats errors
|
||||
* according to our standard API error format
|
||||
*/
|
||||
export const validator = <
|
||||
T extends ZodSchema,
|
||||
Target extends keyof ValidationTargets
|
||||
>(
|
||||
target: Target,
|
||||
schema: T
|
||||
): MiddlewareHandler<
|
||||
any,
|
||||
string,
|
||||
{
|
||||
in: {
|
||||
[K in Target]: z.input<T>;
|
||||
};
|
||||
out: {
|
||||
[K in Target]: z.output<T>;
|
||||
};
|
||||
}
|
||||
> => {
|
||||
// Create a custom error handler that formats errors according to our standards
|
||||
// const standardErrorHandler: Parameters<typeof zodValidator>[2] = (
|
||||
const standardErrorHandler: Hook<z.infer<T>, any, any, Target> = (
|
||||
result,
|
||||
c,
|
||||
) => {
|
||||
if (!result.success) {
|
||||
// Get the validation issues
|
||||
const issues = result.error.issues || result.error.errors || [];
|
||||
if (issues.length === 0) {
|
||||
// If there are no issues, return a generic error
|
||||
return c.json(
|
||||
{
|
||||
type: "validation",
|
||||
code: ErrorCodes.Validation.INVALID_PARAMETER,
|
||||
message: "Invalid request data",
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
// Get the first error for the main response
|
||||
const firstIssue = issues[0]!;
|
||||
const fieldPath = firstIssue.path
|
||||
? Array.isArray(firstIssue.path)
|
||||
? firstIssue.path.join(".")
|
||||
: firstIssue.path
|
||||
: undefined;
|
||||
|
||||
// Map Zod error codes to our standard error codes
|
||||
let errorCode = ErrorCodes.Validation.INVALID_PARAMETER;
|
||||
if (
|
||||
firstIssue.code === "invalid_type" &&
|
||||
firstIssue.received === "undefined"
|
||||
) {
|
||||
errorCode = ErrorCodes.Validation.MISSING_REQUIRED_FIELD;
|
||||
} else if (
|
||||
["invalid_string", "invalid_date", "invalid_regex"].includes(
|
||||
firstIssue.code,
|
||||
)
|
||||
) {
|
||||
errorCode = ErrorCodes.Validation.INVALID_FORMAT;
|
||||
}
|
||||
|
||||
// Create our standardized error response
|
||||
const response = {
|
||||
type: "validation",
|
||||
code: errorCode,
|
||||
message: firstIssue.message,
|
||||
param: fieldPath,
|
||||
details: undefined as any,
|
||||
};
|
||||
|
||||
// Add details if we have multiple issues
|
||||
if (issues.length > 0) {
|
||||
response.details = {
|
||||
issues: issues.map((issue) => ({
|
||||
path: issue.path
|
||||
? Array.isArray(issue.path)
|
||||
? issue.path.join(".")
|
||||
: issue.path
|
||||
: undefined,
|
||||
code: issue.code,
|
||||
message: issue.message,
|
||||
// @ts-expect-error
|
||||
expected: issue.expected,
|
||||
// @ts-expect-error
|
||||
received: issue.received,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
console.log("Validation error in validator:", response);
|
||||
return c.json(response, 400);
|
||||
}
|
||||
};
|
||||
|
||||
// Use the original validator with our custom error handler
|
||||
return zodValidator(target, schema, standardErrorHandler);
|
||||
};
|
||||
|
||||
/**
|
||||
* Standard error responses for OpenAPI documentation
|
||||
*/
|
||||
export const ErrorResponses = {
|
||||
400: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Validation error",
|
||||
example: {
|
||||
type: "validation",
|
||||
code: "invalid_parameter",
|
||||
message: "The request was invalid",
|
||||
param: "email",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description:
|
||||
"Bad Request - The request could not be understood or was missing required parameters.",
|
||||
},
|
||||
401: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Authentication error",
|
||||
example: {
|
||||
type: "authentication",
|
||||
code: "unauthorized",
|
||||
message: "Authentication required",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description:
|
||||
"Unauthorized - Authentication is required and has failed or has not been provided.",
|
||||
},
|
||||
403: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Permission error",
|
||||
example: {
|
||||
type: "forbidden",
|
||||
code: "permission_denied",
|
||||
message: "You do not have permission to access this resource",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description:
|
||||
"Forbidden - You do not have permission to access this resource.",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Not found error",
|
||||
example: {
|
||||
type: "not_found",
|
||||
code: "resource_not_found",
|
||||
message: "The requested resource could not be found",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Not Found - The requested resource does not exist.",
|
||||
},
|
||||
409: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Conflict Error",
|
||||
example: {
|
||||
type: "already_exists",
|
||||
code: "resource_already_exists",
|
||||
message: "The resource could not be created because it already exists",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Conflict - The resource could not be created because it already exists.",
|
||||
},
|
||||
429: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Rate limit error",
|
||||
example: {
|
||||
type: "rate_limit",
|
||||
code: "too_many_requests",
|
||||
message: "Rate limit exceeded",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description:
|
||||
"Too Many Requests - You have made too many requests in a short period of time.",
|
||||
},
|
||||
500: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Server error",
|
||||
example: {
|
||||
type: "internal",
|
||||
code: "internal_error",
|
||||
message: "Internal server error",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Internal Server Error - Something went wrong on our end.",
|
||||
},
|
||||
};
|
||||
93
packages/functions/src/api/friend.ts
Normal file
93
packages/functions/src/api/friend.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
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 { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
import { ErrorResponses, notPublic, Result, validator } from "./utils";
|
||||
|
||||
export namespace GameApi {
|
||||
export const route = new Hono()
|
||||
.use(notPublic)
|
||||
.get("/",
|
||||
describeRoute({
|
||||
tags: ["Game"],
|
||||
summary: "List games",
|
||||
description: "List all the games on this user's library",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Game.Info.array().openapi({
|
||||
description: "All games in the library",
|
||||
example: [Examples.Game]
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "All games in the library"
|
||||
},
|
||||
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
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
137
packages/functions/src/api/image.ts
Normal file
137
packages/functions/src/api/image.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { z } from "zod"
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
S3Client,
|
||||
GetObjectCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import Sharp from "sharp";
|
||||
import { Resource } from "sst";
|
||||
import { validator } from "hono-openapi/zod";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
|
||||
const s3 = new S3Client();
|
||||
|
||||
interface TimingMetrics {
|
||||
download: number;
|
||||
transform: number;
|
||||
upload?: number;
|
||||
}
|
||||
|
||||
const formatTimingHeader = (metrics: TimingMetrics): string => {
|
||||
const timings = [
|
||||
`img-download;dur=${Math.round(metrics.download)}`,
|
||||
`img-transform;dur=${Math.round(metrics.transform)}`,
|
||||
];
|
||||
|
||||
if (metrics.upload !== undefined) {
|
||||
timings.push(`img-upload;dur=${Math.round(metrics.upload)}`);
|
||||
}
|
||||
|
||||
return timings.join(",");
|
||||
};
|
||||
|
||||
|
||||
export namespace ImageApi {
|
||||
export const route = new Hono()
|
||||
.post("/:hash",
|
||||
validator("json",
|
||||
z.object({
|
||||
dpr: z.number().optional(),
|
||||
width: z.number().optional(),
|
||||
height: z.number().optional(),
|
||||
quality: z.number().optional(),
|
||||
format: z.enum(["avif", "webp", "jpeg"]),
|
||||
})
|
||||
),
|
||||
// validator("header",
|
||||
// z.object({
|
||||
// secretKey: z.string(),
|
||||
// })
|
||||
// ),
|
||||
validator("param",
|
||||
z.object({
|
||||
hash: z.string(),
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const input = c.req.valid("json");
|
||||
const { hash } = c.req.valid("param");
|
||||
// const secret = c.req.valid("header").secretKey
|
||||
|
||||
const metrics: TimingMetrics = {
|
||||
download: 0,
|
||||
transform: 0,
|
||||
};
|
||||
|
||||
const downloadStart = performance.now();
|
||||
let originalImage: Buffer;
|
||||
let contentType: string;
|
||||
try {
|
||||
const getCommand = new GetObjectCommand({
|
||||
Bucket: Resource.Storage.name,
|
||||
Key: hash,
|
||||
});
|
||||
const response = await s3.send(getCommand);
|
||||
|
||||
originalImage = Buffer.from(await response.Body!.transformToByteArray());
|
||||
contentType = response.ContentType || "image/jpeg";
|
||||
metrics.download = performance.now() - downloadStart;
|
||||
} catch (error) {
|
||||
throw new HTTPException(500, { message: `Error downloading original image:${error}` });
|
||||
}
|
||||
|
||||
|
||||
const transformStart = performance.now();
|
||||
let transformedImage: Buffer;
|
||||
|
||||
try {
|
||||
let sharpInstance = Sharp(originalImage, {
|
||||
failOn: "none",
|
||||
animated: true,
|
||||
});
|
||||
|
||||
const metadata = await sharpInstance.metadata();
|
||||
|
||||
// Apply transformations
|
||||
if (input.width || input.height) {
|
||||
sharpInstance = sharpInstance.resize({
|
||||
width: input.width,
|
||||
height: input.height,
|
||||
});
|
||||
}
|
||||
|
||||
if (metadata.orientation) {
|
||||
sharpInstance = sharpInstance.rotate();
|
||||
}
|
||||
|
||||
if (input.format) {
|
||||
const isLossy = ["jpeg", "webp", "avif"].includes(input.format);
|
||||
|
||||
if (isLossy && input.quality) {
|
||||
sharpInstance = sharpInstance.toFormat(input.format, {
|
||||
quality: input.quality,
|
||||
});
|
||||
} else {
|
||||
sharpInstance = sharpInstance.toFormat(input.format);
|
||||
}
|
||||
}
|
||||
|
||||
transformedImage = await sharpInstance.toBuffer();
|
||||
metrics.transform = performance.now() - transformStart;
|
||||
|
||||
contentType = `image/${input.format}`;
|
||||
} catch (error) {
|
||||
throw new HTTPException(500, { message: `Error transforming image:${error}` });
|
||||
}
|
||||
|
||||
return c.newResponse(transformedImage,
|
||||
200,
|
||||
{
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "max-age=31536000",
|
||||
"Server-Timing": formatTimingHeader(metrics),
|
||||
},
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user