add:planned feats
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
133
backend/src/routes/auth.ts
Normal 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 });
|
||||
});
|
||||
};
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user