Skip to content
This repository was archived by the owner on Mar 21, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 42 additions & 11 deletions apps/projector/src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { QRCodeSVG } from "qrcode.react";
import type { UpstreamMessage } from "@hackz/shared";
import { useProjectorConnection } from "../webrtc/useProjectorConnection";
import type { ProjectorConnectionState } from "../webrtc/ProjectorConnection";
import { trpc } from "../lib/trpc";

const getStatusColor = (state: ProjectorConnectionState): string => {
switch (state) {
Expand Down Expand Up @@ -32,12 +33,14 @@ const getStatusText = (state: ProjectorConnectionState): string => {
};

const ProjectorPage = () => {
const { state, roomId, open, close, disconnectAdmin, onMessage } = useProjectorConnection();
const { state, roomId, open, close, disconnectAdmin, onMessage, send } = useProjectorConnection();
const openRef = useRef(open);
const closeRef = useRef(close);
openRef.current = open;
closeRef.current = close;

const nfcLoginMutation = trpc.auth.nfcLogin.useMutation();

// 起動時にルームを作成
useEffect(() => {
openRef.current();
Expand All @@ -47,16 +50,44 @@ const ProjectorPage = () => {
}, []);

// Admin からのメッセージを処理
const handleMessage = useCallback((msg: UpstreamMessage) => {
switch (msg.type) {
case "NFC_SCANNED":
// TODO: tRPC で auth.nfcLogin を呼んで結果を Admin に返す
break;
case "QR_SCANNED":
// TODO: QR データを処理して結果を Admin に返す
break;
}
}, []);
const handleMessage = useCallback(
(msg: UpstreamMessage) => {
switch (msg.type) {
case "NFC_SCANNED":
nfcLoginMutation.mutate(
{ nfcId: msg.nfcId },
{
onSuccess: (data) => {
send({
type: "SCAN_RESULT",
success: true,
scanType: "nfc",
message: `${data.user.name} がログインしました`,
});
},
onError: (err) => {
send({
type: "SCAN_RESULT",
success: false,
scanType: "nfc",
message: err.message,
});
},
},
);
break;
case "QR_SCANNED":
send({
type: "SCAN_RESULT",
success: true,
scanType: "qr",
message: `QR データ受信: ${msg.data}`,
});
break;
}
},
[nfcLoginMutation, send],
);

useEffect(() => {
onMessage(handleMessage);
Expand Down
8 changes: 6 additions & 2 deletions apps/projector/src/webrtc/useProjectorConnection.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { UpstreamMessage } from "@hackz/shared";
import type { DownstreamMessage, UpstreamMessage } from "@hackz/shared";
import { ProjectorConnection } from "./ProjectorConnection";
import type { ProjectorConnectionState } from "./ProjectorConnection";
import { trpc } from "../lib/trpc";
Expand Down Expand Up @@ -103,5 +103,9 @@ export const useProjectorConnection = () => {
[],
);

return { state, roomId, open, close, disconnectAdmin, onMessage };
const send = useCallback((msg: DownstreamMessage) => {
connectionRef.current?.send(msg);
}, []);

return { state, roomId, open, close, disconnectAdmin, onMessage, send };
};
27 changes: 21 additions & 6 deletions packages/server/src/trpc/routers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { z } from "zod";
import { nfcLoginInputSchema, userSchema } from "@hackz/shared";
import { publicProcedure, router } from "../trpc";
import { signToken } from "../../lib/jwt";
import { createDynamoDBUserRepository } from "../../repositories/dynamodb/user-repository";

const userRepo = createDynamoDBUserRepository();

export const authRouter = router({
nfcLogin: publicProcedure
Expand All @@ -10,16 +13,28 @@ export const authRouter = router({
.mutation(async ({ input }) => {
const { nfcId } = input;

// TODO: Look up or create user by NFC ID in DynamoDB
const userId = nfcId;
const token = await signToken(userId);
let user = await userRepo.findByNfcId(nfcId);
if (!user) {
user = await userRepo.create({
id: crypto.randomUUID(),
nfcId,
name: `User-${nfcId.slice(0, 6)}`,
totalScore: 0,
createdAt: new Date().toISOString(),
});
}

const token = await signToken(user.id);

return {
token,
user: {
id: userId,
name: `User-${userId.slice(0, 6)}`,
createdAt: new Date().toISOString(),
id: user.id,
name: user.name,
photoUrl: user.photoUrl,
equippedBuildId: user.equippedBuildId,
totalScore: user.totalScore,
createdAt: user.createdAt,
},
};
}),
Expand Down
25 changes: 23 additions & 2 deletions packages/server/src/trpc/routers/costumes.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,35 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { equipBuildInputSchema, costumeSchema } from "@hackz/shared";
import { protectedProcedure, router } from "../trpc";
import { createDynamoDBCostumeRepository } from "../../repositories/dynamodb/costume-repository";
import { createDynamoDBUserCostumeRepository } from "../../repositories/dynamodb/user-costume-repository";
import { createDynamoDBUserRepository } from "../../repositories/dynamodb/user-repository";

const costumeRepo = createDynamoDBCostumeRepository();
const userCostumeRepo = createDynamoDBUserCostumeRepository();
const userRepo = createDynamoDBUserRepository();

export const costumesRouter = router({
list: protectedProcedure
.output(z.object({ costumes: z.array(costumeSchema) }))
.query(async () => ({ costumes: [] })),
.query(async ({ ctx }) => {
const userCostumes = await userCostumeRepo.findByUserId(ctx.userId);
const costumes = await Promise.all(
userCostumes.map((uc) => costumeRepo.findById(uc.costumeId)),
);
return { costumes: costumes.filter((c): c is NonNullable<typeof c> => c !== null) };
}),

equip: protectedProcedure
.input(equipBuildInputSchema)
.output(z.object({ success: z.boolean() }))
.mutation(async () => ({ success: true })),
.mutation(async ({ ctx, input }) => {
const user = await userRepo.findById(ctx.userId);
if (!user) {
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
}
await userRepo.update({ ...user, equippedBuildId: input.buildId });
return { success: true };
}),
});
28 changes: 17 additions & 11 deletions packages/server/src/trpc/routers/gacha.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,29 @@
import { TRPCError } from "@trpc/server";
import { gachaResultSchema } from "@hackz/shared";
import { protectedProcedure, router } from "../trpc";
import { emitProjectorEvent } from "../ee";
import { pullGacha } from "../../domain/gacha";
import { createDynamoDBCostumeRepository } from "../../repositories/dynamodb/costume-repository";
import { createDynamoDBUserCostumeRepository } from "../../repositories/dynamodb/user-costume-repository";

const costumeRepo = createDynamoDBCostumeRepository();
const userCostumeRepo = createDynamoDBUserCostumeRepository();

export const gachaRouter = router({
pull: protectedProcedure.output(gachaResultSchema).mutation(async ({ ctx }) => {
const { rarity } = pullGacha();

// TODO: Fetch actual costume from DynamoDB based on rarity
const costume = {
id: `costume-${Date.now()}`,
name: `Costume (${rarity})`,
rarity,
category: "top" as const,
imageUrl: "https://placeholder.example.com/costume.jpg",
description: "A beautiful costume",
};
const costumes = await costumeRepo.findByRarity(rarity);
if (costumes.length === 0) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `No costumes available for rarity: ${rarity}`,
});
}

const costume = costumes[Math.floor(Math.random() * costumes.length)];
const { isNew } = await userCostumeRepo.acquire(ctx.userId, costume.id);

// Broadcast to projector subscribers
emitProjectorEvent({
type: "gacha:result",
userId: ctx.userId,
Expand All @@ -27,6 +33,6 @@ export const gachaRouter = router({
category: costume.category,
});

return { costume, isNew: true };
return { costume, isNew };
}),
});
41 changes: 23 additions & 18 deletions packages/server/src/trpc/routers/sessions.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,36 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { createSessionInputSchema, sessionSchema } from "@hackz/shared";
import { protectedProcedure, router } from "../trpc";
import { createDynamoDBSessionRepository } from "../../repositories/dynamodb/session-repository";

const sessionRepo = createDynamoDBSessionRepository();

export const sessionsRouter = router({
create: protectedProcedure
.input(createSessionInputSchema)
.output(sessionSchema)
.mutation(async ({ ctx, input }) => ({
id: `session-${Date.now()}`,
userId: ctx.userId,
status: "waiting" as const,
buildId: input.buildId,
photoUrl: "",
progress: 0,
createdAt: new Date().toISOString(),
})),
.mutation(async ({ ctx, input }) => {
const session = await sessionRepo.create({
id: crypto.randomUUID(),
userId: ctx.userId,
status: "waiting",
buildId: input.buildId,
photoUrl: "",
progress: 0,
createdAt: new Date().toISOString(),
});
return session;
}),

get: protectedProcedure
.input(z.object({ id: z.string() }))
.output(sessionSchema)
.query(async ({ ctx, input }) => ({
id: input.id,
userId: ctx.userId,
status: "waiting" as const,
buildId: "",
photoUrl: "",
progress: 0,
createdAt: new Date().toISOString(),
})),
.query(async ({ input }) => {
const session = await sessionRepo.findById(input.id);
if (!session) {
throw new TRPCError({ code: "NOT_FOUND", message: "Session not found" });
}
return session;
}),
});
10 changes: 7 additions & 3 deletions packages/server/src/trpc/routers/subscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { nfcScanInputSchema } from "@hackz/shared";
import type { ProjectorEvent, SessionEvent } from "@hackz/shared";
import { publicProcedure, protectedProcedure, router } from "../trpc";
import { ee, emitProjectorEvent } from "../ee";
import { createDynamoDBUserRepository } from "../../repositories/dynamodb/user-repository";

const userRepo = createDynamoDBUserRepository();

export const subscriptionRouter = router({
// Projector room: NFC scans + gacha results
Expand Down Expand Up @@ -35,11 +38,12 @@ export const subscriptionRouter = router({
nfcScan: protectedProcedure
.input(nfcScanInputSchema)
.output(z.object({ success: z.boolean() }))
.mutation(({ input }) => {
.mutation(async ({ input }) => {
const user = await userRepo.findByNfcId(input.nfcId);
emitProjectorEvent({
type: "nfc:scanned",
userId: input.nfcId,
userName: `User-${input.nfcId.slice(0, 6)}`,
userId: user?.id ?? input.nfcId,
userName: user?.name ?? `User-${input.nfcId.slice(0, 6)}`,
});
return { success: true };
}),
Expand Down
33 changes: 24 additions & 9 deletions packages/server/src/trpc/routers/synthesis.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,41 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import {
startSynthesisInputSchema,
sessionStatusSchema,
synthesisStatusSchema,
} from "@hackz/shared";
import { protectedProcedure, router } from "../trpc";
import { createDynamoDBSessionRepository } from "../../repositories/dynamodb/session-repository";

const sessionRepo = createDynamoDBSessionRepository();

export const synthesisRouter = router({
start: protectedProcedure
.input(startSynthesisInputSchema)
.output(z.object({ sessionId: z.string(), status: sessionStatusSchema }))
.mutation(async ({ input }) => ({
sessionId: input.sessionId,
status: "synthesizing" as const,
})),
.mutation(async ({ input }) => {
const session = await sessionRepo.findById(input.sessionId);
if (!session) {
throw new TRPCError({ code: "NOT_FOUND", message: "Session not found" });
}
const updated = await sessionRepo.update({ ...session, status: "synthesizing", progress: 0 });
return { sessionId: updated.id, status: updated.status };
}),

status: protectedProcedure
.input(z.object({ sessionId: z.string() }))
.output(synthesisStatusSchema)
.query(async ({ input }) => ({
sessionId: input.sessionId,
status: "synthesizing" as const,
progress: 0,
})),
.query(async ({ input }) => {
const session = await sessionRepo.findById(input.sessionId);
if (!session) {
throw new TRPCError({ code: "NOT_FOUND", message: "Session not found" });
}
return {
sessionId: session.id,
status: session.status,
progress: session.progress,
videoUrl: session.videoUrl,
};
}),
});
Loading
Loading