add:planned feats

This commit is contained in:
kokopi
2026-03-09 00:51:07 +09:00
parent 16bc00632d
commit fc611806a3
30 changed files with 950 additions and 129 deletions

View File

@@ -10,10 +10,14 @@
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "latest",
"@fastify/session": "^11.1.1",
"drizzle-orm": "^0.45.1",
"fastify": "latest",
"fastify-plugin": "latest"
"fastify-plugin": "latest",
"google-auth-library": "^10.6.1",
"unique-names-generator": "^4.7.1"
},
"devDependencies": {
"@libsql/client": "^0.17.0",

View File

@@ -1,38 +1,54 @@
import { eq } from 'drizzle-orm'
import { db } from '../db/index.ts'
import { tickets } from '../db/schema.ts'
import type { Ticket, StorageAdapter } from '../types.ts'
import type { StorageAdapter, Ticket, TicketType } from '../types.ts'
export class SQLiteAdapter implements StorageAdapter {
getTickets(): Ticket[] {
return db.select().from(tickets).all()
async getTickets(): Promise<Ticket[]> {
const rows = await db.select().from(tickets).orderBy(tickets.createdAt)
return rows.map(toTicket).reverse()
}
getTicket(id: string): Ticket | null {
return db.select().from(tickets).where(eq(tickets.id, id)).get() ?? null
async getTicket(id: string): Promise<Ticket | null> {
const rows = await db.select().from(tickets).where(eq(tickets.id, id))
return rows[0] ? toTicket(rows[0]) : null
}
createTicket({ subject, description, type }: Pick<Ticket, 'subject' | 'description' | 'type'>): Ticket {
const ticket: Ticket = {
id: crypto.randomUUID(),
subject,
description,
async createTicket(
data: Pick<Ticket, 'subject' | 'description' | 'type'> & { userId?: string }
): Promise<Ticket> {
const id = crypto.randomUUID()
const now = new Date().toISOString()
await db.insert(tickets).values({
id,
userId: data.userId ?? null,
subject: data.subject,
description: data.description,
type: data.type,
status: 'open',
type,
createdAt: new Date().toISOString(),
}
db.insert(tickets).values(ticket).run()
return ticket
createdAt: now,
})
return (await this.getTicket(id))!
}
updateTicket(id: string, patch: Partial<Ticket>): Ticket | null {
const current = this.getTicket(id)
if (!current) return null
db.update(tickets).set(patch).where(eq(tickets.id, id)).run()
return { ...current, ...patch }
async updateTicket(id: string, patch: Partial<Ticket>): Promise<Ticket | null> {
await db.update(tickets).set(patch).where(eq(tickets.id, id))
return this.getTicket(id)
}
deleteTicket(id: string): void {
db.delete(tickets).where(eq(tickets.id, id)).run()
async deleteTicket(id: string): Promise<void> {
await db.delete(tickets).where(eq(tickets.id, id))
}
}
function toTicket(row: typeof tickets.$inferSelect): Ticket {
return {
id: row.id,
userId: row.userId,
subject: row.subject,
description: row.description,
type: row.type as TicketType,
status: row.status as Ticket['status'],
createdAt: row.createdAt,
}
}

View File

@@ -23,3 +23,11 @@ export const tickets = sqliteTable("tickets", {
.default("open"),
createdAt: text("createdAt").notNull(),
});
export const users = sqliteTable('users', {
id: text('id').primaryKey(), // internal UUID
googleId: text('googleId').notNull().unique(), // Google's `sub` claim
username: text('username').notNull().unique(), // generated: "silent-crimson-falcon"
avatarUrl: text('avatarUrl'), // Google profile picture
createdAt: text('createdAt').notNull(),
})

View File

@@ -1,19 +1,38 @@
import Fastify from 'fastify'
import cors from '@fastify/cors'
import cookie from '@fastify/cookie'
import session from '@fastify/session'
import { authMiddleware } from './middleware/auth.ts'
import { storageMiddleware } from './middleware/storage.ts'
import { storageModeRouter } from './routes/storageMode.ts'
import { ticketsRouter } from './routes/tickets.ts'
import { authRouter } from './routes/auth.ts'
const app = Fastify({ logger: true })
const PORT = Number(process.env.PORT) || 3000
const PORT = Number(process.env.PORT) || 4500
await app.register(cors, {
origin: process.env.FRONTEND_URL ?? 'http://localhost:5173',
credentials: true,
})
await app.register(cookie)
await app.register(session, {
secret: process.env.SESSION_SECRET ?? 'dev-secret-change-in-production-min-32-chars!!',
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
},
})
await app.register(cors)
await app.register(authMiddleware)
await app.register(storageMiddleware)
await app.register(storageModeRouter, { prefix: '/api/storage-mode' })
await app.register(ticketsRouter, { prefix: '/api/tickets' })
app.listen({ port: PORT }, () => {
console.log(`Backend running on http://localhost:${PORT}`)
})
await app.register(storageModeRouter, { prefix: '/api/storage-mode' })
await app.register(ticketsRouter, { prefix: '/api/tickets' })
await app.register(authRouter, { prefix: '/api/auth' })
await app.listen({ port: PORT })
console.log(`Backend running on http://localhost:${PORT}`)

View File

@@ -1,16 +1,30 @@
import fp from 'fastify-plugin'
import type { FastifyPluginAsync } from 'fastify'
import type { User } from '../types.ts'
declare module 'fastify' {
interface FastifyRequest {
isAuthenticated: boolean
user: User | null
}
interface Session {
userId?: string
user?: User
}
}
export const authMiddleware: FastifyPluginAsync = fp(async (app) => {
app.decorateRequest('isAuthenticated', false)
app.decorateRequest('user', null)
app.addHook('onRequest', async (req) => {
// hardcoded false — replace with real session/token check when auth is implemented
req.isAuthenticated = false
const sessionUser = req.session?.user
if (sessionUser) {
req.isAuthenticated = true
req.user = sessionUser
} else {
req.isAuthenticated = false
req.user = null
}
})
})

View File

@@ -9,13 +9,11 @@ declare module 'fastify' {
}
}
const adapter: StorageAdapter = new SQLiteAdapter()
const adapter = new SQLiteAdapter()
const plugin: FastifyPluginAsync = async (app) => {
app.decorateRequest('storage', null)
app.addHook('onRequest', async (req) => {
req.storage = adapter
})
}
export const storageMiddleware = fp(plugin)
export const storageMiddleware: FastifyPluginAsync = fp(async (app) => {
app.decorateRequest('storage', { getter: () => adapter })
// app.addHook('onRequest', async (req) => {
// req.storage = adapter
// })
})

133
backend/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,133 @@
import type { FastifyPluginAsync } from "fastify";
import { OAuth2Client } from "google-auth-library";
import { eq } from "drizzle-orm";
import {
uniqueNamesGenerator,
adjectives,
colors,
animals,
} from "unique-names-generator";
import { db } from "../db/index.ts";
import { users } from "../db/schema.ts";
import type { User } from "../types.ts";
const CLIENT_ID = process.env.GOOGLE_CLIENT_ID ?? "";
const CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET ?? "";
const REDIRECT_URI =
process.env.GOOGLE_REDIRECT_URI ?? "http://localhost:4500/api/auth/callback";
const FRONTEND_URL = process.env.FRONTEND_URL ?? "http://localhost:5173";
const oauthClient = new OAuth2Client(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI);
function generateUsername(): string {
return uniqueNamesGenerator({
dictionaries: [adjectives, animals],
separator: " ",
length: 3,
});
}
export const authRouter: FastifyPluginAsync = async (app) => {
// ── Step 1: Redirect to Google ──────────────────────────────
app.get("/google", async (_req, reply) => {
const url = oauthClient.generateAuthUrl({
access_type: "online",
// Only request openid + profile — no email scope
scope: ["openid", "profile"],
prompt: "select_account",
});
return reply.redirect(url);
});
// ── Step 2: Google redirects back here ──────────────────────
app.get<{ Querystring: { code?: string; error?: string } }>(
"/callback",
async (req, reply) => {
const { code, error } = req.query;
if (error || !code) {
return reply.redirect(`${FRONTEND_URL}?error=oauth_denied`);
}
try {
const { tokens } = await oauthClient.getToken(code);
oauthClient.setCredentials(tokens);
const ticket = await oauthClient.verifyIdToken({
idToken: tokens.id_token!,
audience: CLIENT_ID,
});
const payload = ticket.getPayload();
if (!payload?.sub) {
return reply.redirect(`${FRONTEND_URL}?error=invalid_token`);
}
const googleId = payload.sub;
const avatarUrl = payload.picture ?? null;
// Find or create user
const existing = await db
.select()
.from(users)
.where(eq(users.googleId, googleId))
.limit(1);
let user: User;
if (existing[0]) {
// Returning user — refresh avatar
await db
.update(users)
.set({ avatarUrl })
.where(eq(users.googleId, googleId));
user = {
id: existing[0].id,
googleId: existing[0].googleId,
username: existing[0].username,
avatarUrl: avatarUrl ?? existing[0].avatarUrl,
createdAt: existing[0].createdAt,
};
} else {
// New user — generate readable username
let username = generateUsername();
// Retry on collision (very rare)
const collision = await db
.select({ id: users.id })
.from(users)
.where(eq(users.username, username))
.limit(1);
if (collision[0]) {
username = `${generateUsername()}-${Math.floor(Math.random() * 999)}`;
}
const id = crypto.randomUUID();
const now = new Date().toISOString();
await db
.insert(users)
.values({ id, googleId, username, avatarUrl, createdAt: now });
user = { id, googleId, username, avatarUrl, createdAt: now };
}
req.session.user = user;
return reply.redirect(FRONTEND_URL);
} catch (err) {
app.log.error(err, "OAuth callback error");
return reply.redirect(`${FRONTEND_URL}?error=server_error`);
}
},
);
// /api/auth/me
app.get("/me", async (req, reply) => {
if (!req.isAuthenticated || !req.user) {
return reply.status(401).send({ user: null });
}
return reply.send({ user: req.user });
});
// /api/auth/logout
app.post("/logout", async (req, reply) => {
await req.session.destroy();
return reply.send({ ok: true });
});
};

View File

@@ -1,3 +1,11 @@
export interface User {
id: string
googleId: string
username: string
avatarUrl: string | null
createdAt: string
}
export type TicketType =
| "bug"
| "billing"
@@ -7,18 +15,19 @@ export type TicketType =
| "other";
export interface Ticket {
id: string;
subject: string;
description: string;
status: "open" | "in-progress" | "resolved" | "closed";
type: TicketType;
createdAt: string;
id: string
userId: string | null
subject: string
description: string
type: TicketType
status: 'open' | 'in-progress' | 'resolved' | 'closed'
createdAt: string
}
export interface StorageAdapter {
getTickets(): Ticket[];
getTicket(id: string): Ticket | null;
createTicket(data: Pick<Ticket, "subject" | "description" | "type">): Ticket;
updateTicket(id: string, patch: Partial<Ticket>): Ticket | null;
deleteTicket(id: string): void;
getTickets(): Promise<Ticket[]>
getTicket(id: string): Promise<Ticket | null>
createTicket(data: Pick<Ticket, 'subject' | 'description' | 'type'> & { userId?: string }): Promise<Ticket>
updateTicket(id: string, patch: Partial<Ticket>): Promise<Ticket | null>
deleteTicket(id: string): Promise<void>
}