basic working app currently wworking on the sync to work
Build APK / build (push) Canceled after 0s

This commit is contained in:
2026-08-06 11:16:47 +02:00
commit 793e2d9044
192 changed files with 42523 additions and 0 deletions
+199
View File
@@ -0,0 +1,199 @@
import { Router, Request, Response } from 'express';
import { asyncHandler } from '../utils/asyncHandler';
import { db } from '../db';
import { users } from '../db/schema';
import { eq } from 'drizzle-orm';
import { generateToken, generateId, getCurrentTimestamp } from '../utils/auth';
import { AppError } from '../middleware/errorHandler';
import { z } from 'zod';
import bcrypt from 'bcryptjs';
import rateLimit from 'express-rate-limit';
import crypto from 'crypto';
const router = Router();
// Rate limiting for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // limit each IP to 10 requests per windowMs
message: {
error: {
code: 'RATE_LIMITED',
message: 'Too many attempts. Please try again later.',
},
},
standardHeaders: true,
legacyHeaders: false,
});
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // stricter limit for login
message: {
error: {
code: 'RATE_LIMITED',
message: 'Too many login attempts. Please try again later.',
},
},
standardHeaders: true,
legacyHeaders: false,
});
const passwordResetLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 3, // very strict for password reset
message: {
error: {
code: 'RATE_LIMITED',
message: 'Too many password reset requests. Please try again later.',
},
},
standardHeaders: true,
legacyHeaders: false,
});
const usernameSchema = z
.string()
.min(3)
.max(30)
.regex(/^[a-zA-Z0-9_.-]+$/, 'Username can only contain letters, numbers, dots, dashes and underscores');
const registerSchema = z.object({
username: usernameSchema,
password: z.string().min(8),
});
const loginSchema = z.object({
username: z.string(),
password: z.string(),
});
const forgotPasswordSchema = z.object({
username: z.string().min(1),
});
const resetPasswordSchema = z.object({
token: z.string().min(1),
password: z.string().min(8),
});
const BCRYPT_ROUNDS = 12;
async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, BCRYPT_ROUNDS);
}
async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
function generateResetToken(): string {
return crypto.randomBytes(32).toString('hex');
}
router.post('/register', authLimiter, asyncHandler(async (req: Request, res: Response) => {
const data = registerSchema.parse(req.body);
const existing = await db.select().from(users).where(eq(users.username, data.username)).limit(1);
if (existing.length > 0) {
throw new AppError('USERNAME_EXISTS', 'Username already registered', 409);
}
const userId = generateId('user');
const now = getCurrentTimestamp();
await db.insert(users).values({
id: userId,
username: data.username,
passwordHash: await hashPassword(data.password),
createdAt: now,
});
const token = generateToken({ userId, username: data.username });
res.status(201).json({
user: { id: userId, username: data.username },
token,
});
}));
router.post('/login', loginLimiter, asyncHandler(async (req: Request, res: Response) => {
const data = loginSchema.parse(req.body);
const user = await db.select().from(users).where(eq(users.username, data.username)).limit(1);
if (user.length === 0 || !verifyPassword(data.password, user[0].passwordHash)) {
throw new AppError('INVALID_CREDENTIALS', 'Invalid username or password', 401);
}
const token = generateToken({ userId: user[0].id, username: user[0].username });
res.json({
user: { id: user[0].id, username: user[0].username },
token,
});
}));
router.get('/me', asyncHandler(async (req: Request, res: Response) => {
if (!req.user) {
throw new AppError('UNAUTHORIZED', 'Authentication required', 401);
}
const user = await db.select().from(users).where(eq(users.id, req.user.userId)).limit(1);
if (user.length === 0) {
throw new AppError('NOT_FOUND', 'User not found', 404);
}
res.json({ id: user[0].id, username: user[0].username });
}));
router.post('/forgot-password', passwordResetLimiter, asyncHandler(async (req: Request, res: Response) => {
const data = forgotPasswordSchema.parse(req.body);
const user = await db.select().from(users).where(eq(users.username, data.username)).limit(1);
// Always return success to prevent username enumeration
if (user.length === 0) {
res.json({ success: true });
return;
}
const resetToken = generateResetToken();
const resetTokenExpiry = Date.now() + 3600000; // 1 hour
await db
.update(users)
.set({ resetToken, resetTokenExpiry })
.where(eq(users.id, user[0].id));
// In production, send email with reset link
// For now, return token in response (dev only)
if (process.env.NODE_ENV !== 'production') {
res.json({ success: true, resetToken });
} else {
res.json({ success: true });
}
}));
router.post('/reset-password', passwordResetLimiter, asyncHandler(async (req: Request, res: Response) => {
const data = resetPasswordSchema.parse(req.body);
const user = await db
.select()
.from(users)
.where(eq(users.resetToken, data.token))
.limit(1);
if (user.length === 0 || !user[0].resetTokenExpiry || user[0].resetTokenExpiry < Date.now()) {
throw new AppError('INVALID_TOKEN', 'Invalid or expired reset token', 400);
}
const newPasswordHash = await hashPassword(data.password);
await db
.update(users)
.set({ passwordHash: newPasswordHash, resetToken: null, resetTokenExpiry: null })
.where(eq(users.id, user[0].id));
res.json({ success: true });
}));
export default router;
+95
View File
@@ -0,0 +1,95 @@
import { Router, Request, Response } from 'express';
import { asyncHandler } from '../utils/asyncHandler';
import { db } from '../db';
import { categories } from '../db/schema';
import { eq, and, desc, asc } from 'drizzle-orm';
import { authMiddleware } from '../utils/auth';
import { AppError } from '../middleware/errorHandler';
import { categoryCreateSchema, categoryUpdateSchema } from '../utils/validation';
const router = Router();
router.use(authMiddleware);
router.get('/', asyncHandler(async (req: Request, res: Response) => {
const userCategories = await db
.select()
.from(categories)
.where(eq(categories.userId, req.user!.userId))
.orderBy(asc(categories.order), asc(categories.createdAt));
res.json({ categories: userCategories });
}));
router.post('/', asyncHandler(async (req: Request, res: Response) => {
const data = categoryCreateSchema.parse(req.body);
const userId = req.user!.userId;
const now = Date.now();
const maxOrder = await db
.select({ order: categories.order })
.from(categories)
.where(eq(categories.userId, userId))
.orderBy(desc(categories.order))
.limit(1);
const newCategory = {
id: `cat_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
userId,
name: data.name,
color: data.color,
order: data.order ?? (maxOrder[0]?.order ?? -1) + 1,
createdAt: now,
updatedAt: now,
};
await db.insert(categories).values(newCategory);
res.status(201).json(newCategory);
}));
router.patch('/:id', asyncHandler(async (req: Request, res: Response) => {
const data = categoryUpdateSchema.parse(req.body);
const userId = req.user!.userId;
const existing = await db
.select()
.from(categories)
.where(and(eq(categories.id, req.params.id), eq(categories.userId, userId)))
.limit(1);
if (existing.length === 0) {
throw new AppError('NOT_FOUND', 'Category not found', 404);
}
const now = Date.now();
const updated = await db
.update(categories)
.set({ ...data, updatedAt: now })
.where(and(eq(categories.id, req.params.id), eq(categories.userId, userId)))
.returning();
res.json(updated[0]);
}));
router.delete('/:id', asyncHandler(async (req: Request, res: Response) => {
const userId = req.user!.userId;
const existing = await db
.select()
.from(categories)
.where(and(eq(categories.id, req.params.id), eq(categories.userId, userId)))
.limit(1);
if (existing.length === 0) {
throw new AppError('NOT_FOUND', 'Category not found', 404);
}
await db
.delete(categories)
.where(and(eq(categories.id, req.params.id), eq(categories.userId, userId)));
res.status(204).send();
}));
export default router;
+194
View File
@@ -0,0 +1,194 @@
import { Router, Request, Response } from 'express';
import { asyncHandler } from '../utils/asyncHandler';
import { db } from '../db';
import { users, friendships } from '../db/schema';
import { eq, and, or, ilike, ne, asc } from 'drizzle-orm';
import { generateId } from '../utils/auth';
import { authMiddleware } from '../utils/auth';
import { AppError } from '../middleware/errorHandler';
import { friendRequestSchema, searchQuerySchema } from '../utils/validation';
const router = Router();
router.use(authMiddleware);
interface FriendRow {
id: string;
username: string;
requestId?: string;
status?: string;
}
async function findUsernames(ids: string[]): Promise<Map<string, string>> {
if (ids.length === 0) return new Map();
const rows = await db.select().from(users).where(or(...ids.map((id) => eq(users.id, id))));
return new Map(rows.map((u) => [u.id, u.username]));
}
router.get('/', asyncHandler(async (req: Request, res: Response) => {
const userId = req.user!.userId;
const outgoingRows = await db
.select()
.from(friendships)
.where(and(eq(friendships.userId, userId), eq(friendships.status, 'pending')));
const incomingRows = await db
.select()
.from(friendships)
.where(and(eq(friendships.friendId, userId), eq(friendships.status, 'pending')));
const acceptedRows = await db
.select()
.from(friendships)
.where(and(
or(eq(friendships.userId, userId), eq(friendships.friendId, userId)),
eq(friendships.status, 'accepted')
));
const friendIds = acceptedRows.map((r) => (r.userId === userId ? r.friendId : r.userId));
const outgoingIds = outgoingRows.map((r) => r.friendId);
const incomingIds = incomingRows.map((r) => r.userId);
const usernames = await findUsernames([...friendIds, ...outgoingIds, ...incomingIds]);
const friends: FriendRow[] = acceptedRows.map((r) => {
const friendId = r.userId === userId ? r.friendId : r.userId;
return { id: friendId, username: usernames.get(friendId) ?? '' };
});
const outgoing: FriendRow[] = outgoingRows.map((r) => ({
id: r.id,
username: usernames.get(r.friendId) ?? '',
requestId: r.id,
status: 'pending',
}));
const incoming: FriendRow[] = incomingRows.map((r) => ({
id: r.userId,
username: usernames.get(r.userId) ?? '',
requestId: r.id,
status: 'pending',
}));
res.json({ friends, incoming, outgoing });
}));
router.get('/search', asyncHandler(async (req: Request, res: Response) => {
const { q } = searchQuerySchema.parse(req.query);
const userId = req.user!.userId;
const results = await db
.select({ id: users.id, username: users.username })
.from(users)
.where(and(
ne(users.id, userId),
ilike(users.username, `%${q}%`)
))
.orderBy(asc(users.username))
.limit(20);
res.json(results);
}));
router.post('/requests', asyncHandler(async (req: Request, res: Response) => {
const { username } = friendRequestSchema.parse(req.body);
const userId = req.user!.userId;
if (username.toLowerCase() === (await meUsername(userId))) {
throw new AppError('SELF_REQUEST', 'You cannot add yourself', 400);
}
const target = await db.select().from(users).where(eq(users.username, username)).limit(1);
if (target.length === 0) {
throw new AppError('USER_NOT_FOUND', 'No user with that username found', 404);
}
const existing = await db
.select()
.from(friendships)
.where(or(
and(eq(friendships.userId, userId), eq(friendships.friendId, target[0].id)),
and(eq(friendships.userId, target[0].id), eq(friendships.friendId, userId))
))
.limit(1);
if (existing.length > 0) {
throw new AppError(
'ALREADY_FRIENDS',
existing[0].status === 'accepted' ? 'You are already friends' : 'Friend request already pending',
409
);
}
const now = Date.now();
await db.insert(friendships).values({
id: generateId('friend'),
userId,
friendId: target[0].id,
status: 'pending',
createdAt: now,
updatedAt: now,
});
res.status(201).json({ success: true });
}));
router.post('/requests/:id/accept', asyncHandler(async (req: Request, res: Response) => {
const userId = req.user!.userId;
const requestId = req.params.id;
const row = await db.select().from(friendships).where(eq(friendships.id, requestId)).limit(1);
if (row.length === 0) {
throw new AppError('NOT_FOUND', 'Request not found', 404);
}
if (row[0].friendId !== userId) {
throw new AppError('FORBIDDEN', 'This request was not sent to you', 403);
}
if (row[0].status !== 'pending') {
throw new AppError('INVALID_STATE', 'Request is no longer pending', 409);
}
await db
.update(friendships)
.set({ status: 'accepted', updatedAt: Date.now() })
.where(eq(friendships.id, requestId));
res.json({ success: true });
}));
router.delete('/requests/:id', asyncHandler(async (req: Request, res: Response) => {
const userId = req.user!.userId;
const requestId = req.params.id;
const row = await db.select().from(friendships).where(eq(friendships.id, requestId)).limit(1);
if (row.length === 0) {
throw new AppError('NOT_FOUND', 'Request not found', 404);
}
if (row[0].userId !== userId && row[0].friendId !== userId) {
throw new AppError('FORBIDDEN', 'Not allowed', 403);
}
await db.delete(friendships).where(eq(friendships.id, requestId));
res.json({ success: true });
}));
router.delete('/:friendId', asyncHandler(async (req: Request, res: Response) => {
const userId = req.user!.userId;
const friendId = req.params.friendId;
await db
.delete(friendships)
.where(or(
and(eq(friendships.userId, userId), eq(friendships.friendId, friendId)),
and(eq(friendships.userId, friendId), eq(friendships.friendId, userId))
));
res.json({ success: true });
}));
async function meUsername(userId: string): Promise<string> {
const me = await db.select().from(users).where(eq(users.id, userId)).limit(1);
return me.length > 0 ? me[0].username.toLowerCase() : '';
}
export default router;
+90
View File
@@ -0,0 +1,90 @@
import { Router, Request, Response } from 'express';
import { asyncHandler } from '../utils/asyncHandler';
import { db } from '../db';
import { repeatProfiles } from '../db/schema';
import { eq, and, asc } from 'drizzle-orm';
import { authMiddleware } from '../utils/auth';
import { generateId } from '../utils/auth';
import { AppError } from '../middleware/errorHandler';
import { repeatProfileSchema, repeatProfileUpdateSchema } from '../utils/validation';
const router = Router();
router.use(authMiddleware);
router.get('/', asyncHandler(async (req: Request, res: Response) => {
const profiles = await db
.select()
.from(repeatProfiles)
.where(eq(repeatProfiles.userId, req.user!.userId))
.orderBy(asc(repeatProfiles.createdAt));
res.json({ profiles });
}));
router.post('/', asyncHandler(async (req: Request, res: Response) => {
const data = repeatProfileSchema.parse(req.body);
const userId = req.user!.userId;
const now = Date.now();
const newProfile = {
id: generateId('rp'),
userId,
name: data.name,
repeat: data.repeat,
repeatInterval: data.repeatInterval,
repeatDays: data.repeatDays,
createdAt: now,
updatedAt: now,
};
await db.insert(repeatProfiles).values(newProfile);
res.status(201).json(newProfile);
}));
router.patch('/:id', asyncHandler(async (req: Request, res: Response) => {
const data = repeatProfileUpdateSchema.parse(req.body);
const userId = req.user!.userId;
const existing = await db
.select()
.from(repeatProfiles)
.where(and(eq(repeatProfiles.id, req.params.id), eq(repeatProfiles.userId, userId)))
.limit(1);
if (existing.length === 0) {
throw new AppError('NOT_FOUND', 'Repeat profile not found', 404);
}
const now = Date.now();
const updated = await db
.update(repeatProfiles)
.set({ ...data, updatedAt: now })
.where(and(eq(repeatProfiles.id, req.params.id), eq(repeatProfiles.userId, userId)))
.returning();
res.json(updated[0]);
}));
router.delete('/:id', asyncHandler(async (req: Request, res: Response) => {
const userId = req.user!.userId;
const existing = await db
.select()
.from(repeatProfiles)
.where(and(eq(repeatProfiles.id, req.params.id), eq(repeatProfiles.userId, userId)))
.limit(1);
if (existing.length === 0) {
throw new AppError('NOT_FOUND', 'Repeat profile not found', 404);
}
await db
.delete(repeatProfiles)
.where(and(eq(repeatProfiles.id, req.params.id), eq(repeatProfiles.userId, userId)));
res.status(204).send();
}));
export default router;
+154
View File
@@ -0,0 +1,154 @@
import { Router, Request, Response } from 'express';
import { asyncHandler } from '../utils/asyncHandler';
import { db } from '../db';
import { subtasks, tasks } from '../db/schema';
import { eq, and, asc, desc } from 'drizzle-orm';
import { authMiddleware } from '../utils/auth';
import { AppError } from '../middleware/errorHandler';
import { subtaskCreateSchema, subtaskUpdateSchema } from '../utils/validation';
const router = Router();
router.use(authMiddleware);
router.get('/task/:taskId', asyncHandler(async (req: Request, res: Response) => {
const userId = req.user!.userId;
// Verify task exists and belongs to user
const task = await db
.select()
.from(tasks)
.where(and(eq(tasks.id, req.params.taskId), eq(tasks.userId, userId)))
.limit(1);
if (task.length === 0) {
throw new AppError('NOT_FOUND', 'Task not found', 404);
}
const taskSubtasks = await db
.select()
.from(subtasks)
.where(and(eq(subtasks.taskId, req.params.taskId), eq(subtasks.userId, userId)))
.orderBy(asc(subtasks.order));
res.json({ subtasks: taskSubtasks });
}));
router.post('/task/:taskId', asyncHandler(async (req: Request, res: Response) => {
const data = subtaskCreateSchema.parse(req.body);
const userId = req.user!.userId;
const task = await db
.select()
.from(tasks)
.where(and(eq(tasks.id, req.params.taskId), eq(tasks.userId, userId)))
.limit(1);
if (task.length === 0) {
throw new AppError('NOT_FOUND', 'Task not found', 404);
}
const now = Date.now();
const maxOrder = await db
.select({ order: subtasks.order })
.from(subtasks)
.where(and(eq(subtasks.taskId, req.params.taskId), eq(subtasks.userId, userId)))
.orderBy(desc(subtasks.order))
.limit(1);
const subtaskId = `sub_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
const newSubtask = {
id: subtaskId,
userId,
taskId: req.params.taskId,
title: data.title,
description: data.description ?? '',
priority: data.priority ?? 'none',
completed: false,
dueDate: data.dueDate ?? 0,
dueTime: data.dueTime ?? '',
endTime: data.endTime ?? '',
allDay: data.allDay ?? false,
repeat: data.repeat ?? 'none',
repeatInterval: data.repeatInterval ?? 1,
repeatDays: data.repeatDays ?? '',
seriesId: data.seriesId ?? '',
reminder: data.reminder ?? 'none',
reminders: data.reminders ?? '',
assigneeId: data.assigneeId ?? null,
order: data.order ?? (maxOrder[0]?.order ?? -1) + 1,
createdAt: now,
updatedAt: now,
};
await db.insert(subtasks).values(newSubtask);
// Update task updatedAt
await db
.update(tasks)
.set({ updatedAt: now })
.where(and(eq(tasks.id, req.params.taskId), eq(tasks.userId, userId)));
res.status(201).json(newSubtask);
}));
router.patch('/:id', asyncHandler(async (req: Request, res: Response) => {
const data = subtaskUpdateSchema.parse(req.body);
const userId = req.user!.userId;
const existing = await db
.select()
.from(subtasks)
.where(and(eq(subtasks.id, req.params.id), eq(subtasks.userId, userId)))
.limit(1);
if (existing.length === 0) {
throw new AppError('NOT_FOUND', 'Subtask not found', 404);
}
const now = Date.now();
const updated = await db
.update(subtasks)
.set({ ...data, updatedAt: now })
.where(and(eq(subtasks.id, req.params.id), eq(subtasks.userId, userId)))
.returning();
// Update task updatedAt
await db
.update(tasks)
.set({ updatedAt: now })
.where(and(eq(tasks.id, existing[0].taskId), eq(tasks.userId, userId)));
res.json(updated[0]);
}));
router.delete('/:id', asyncHandler(async (req: Request, res: Response) => {
const userId = req.user!.userId;
const existing = await db
.select()
.from(subtasks)
.where(and(eq(subtasks.id, req.params.id), eq(subtasks.userId, userId)))
.limit(1);
if (existing.length === 0) {
throw new AppError('NOT_FOUND', 'Subtask not found', 404);
}
const taskId = existing[0].taskId;
await db
.delete(subtasks)
.where(and(eq(subtasks.id, req.params.id), eq(subtasks.userId, userId)));
// Update task updatedAt
await db
.update(tasks)
.set({ updatedAt: Date.now() })
.where(and(eq(tasks.id, taskId), eq(tasks.userId, userId)));
res.status(204).send();
}));
export default router;
+381
View File
@@ -0,0 +1,381 @@
import { Router, Request, Response } from 'express';
import { asyncHandler } from '../utils/asyncHandler';
import { db } from '../db';
import { categories, tasks, subtasks, repeatProfiles, users, friendships } from '../db/schema';
import { eq, and, gte, lte, asc, or, inArray, sql } from 'drizzle-orm';
import { authMiddleware } from '../utils/auth';
import { AppError } from '../middleware/errorHandler';
import { syncQuerySchema, pushChangesSchema, canCompleteTask } from '../utils/validation';
const router = Router();
router.use(authMiddleware);
router.get('/', asyncHandler(async (req: Request, res: Response) => {
const { since } = syncQuerySchema.parse(req.query);
const userId = req.user!.userId;
const sinceDate = since;
// Fetch categories changed since timestamp
const changedCategories = await db
.select()
.from(categories)
.where(and(eq(categories.userId, userId), gte(categories.updatedAt, sinceDate)))
.orderBy(asc(categories.updatedAt));
// Fetch tasks changed since timestamp
const changedTasks = await db
.select()
.from(tasks)
.where(and(eq(tasks.userId, userId), gte(tasks.updatedAt, sinceDate)))
.orderBy(asc(tasks.updatedAt));
// Fetch subtasks changed since timestamp
const taskIds = changedTasks.map(t => t.id);
let changedSubtasks: any[] = [];
if (taskIds.length > 0) {
changedSubtasks = await db
.select()
.from(subtasks)
.where(and(eq(subtasks.userId, userId), gte(subtasks.updatedAt, sinceDate)))
.orderBy(asc(subtasks.updatedAt));
} else {
// Also fetch subtasks for tasks that might have been deleted (we track by updatedAt)
changedSubtasks = await db
.select()
.from(subtasks)
.where(and(eq(subtasks.userId, userId), gte(subtasks.updatedAt, sinceDate)))
.orderBy(asc(subtasks.updatedAt));
}
// Fetch repeat profiles changed since timestamp
const changedRepeatProfiles = await db
.select()
.from(repeatProfiles)
.where(and(eq(repeatProfiles.userId, userId), gte(repeatProfiles.updatedAt, sinceDate)))
.orderBy(asc(repeatProfiles.updatedAt));
// Fetch friendships changed since timestamp
const changedFriendships = await db
.select()
.from(friendships)
.where(and(
or(eq(friendships.userId, userId), eq(friendships.friendId, userId)),
gte(friendships.updatedAt, sinceDate)
))
.orderBy(asc(friendships.updatedAt));
const timestamp = Date.now();
res.json({
categories: changedCategories,
tasks: changedTasks,
subtasks: changedSubtasks,
repeatProfiles: changedRepeatProfiles,
friendships: changedFriendships,
timestamp,
});
}));
router.post('/push', asyncHandler(async (req: Request, res: Response) => {
const data = pushChangesSchema.parse(req.body);
const userId = req.user!.userId;
const conflicts: any[] = [];
const timestamp = Date.now();
try {
await db.transaction(async (tx) => {
// Ensure every referenced category exists (FK integrity) so a stale or
// never-synced category reference cannot fail the entire push. Missing
// categories are recreated as a fallback and the client heals on pull.
const referencedCategoryIds = new Set<string>();
for (const task of data.changes.tasks ?? []) {
if (task.categoryId) referencedCategoryIds.add(task.categoryId);
}
for (const sub of data.changes.subtasks ?? []) {
const task = (data.changes.tasks ?? []).find((t) => t.id === sub.taskId);
if (task?.categoryId) referencedCategoryIds.add(task.categoryId);
}
if (referencedCategoryIds.size > 0) {
const existing = await tx
.select({ id: categories.id })
.from(categories)
.where(inArray(categories.id, [...referencedCategoryIds]));
const existingIds = new Set(existing.map((c) => c.id));
const missing = [...referencedCategoryIds].filter((id) => !existingIds.has(id));
if (missing.length > 0) {
const rows = await tx
.select({ max: sql<number>`max(${categories.order})` })
.from(categories)
.where(eq(categories.userId, userId));
const startOrder = (rows[0]?.max ?? -1) + 1;
await tx.insert(categories).values(
missing.map((id, i) => ({
id,
userId,
name: 'Default',
color: '#9E9E9E',
order: startOrder + i,
createdAt: Date.now(),
updatedAt: Date.now(),
}))
);
}
}
// Process categories
if (data.changes.categories && data.changes.categories.length > 0) {
for (const cat of data.changes.categories) {
const existing = await tx
.select()
.from(categories)
.where(and(eq(categories.id, cat.id), eq(categories.userId, userId)))
.limit(1);
if (existing.length > 0) {
// Check for conflict
if (existing[0].updatedAt > cat.updatedAt) {
conflicts.push({
entity: 'categories',
id: cat.id,
serverVersion: existing[0],
clientVersion: cat,
resolution: 'server_wins',
});
continue; // Server wins
}
await tx
.update(categories)
.set({
name: cat.name,
color: cat.color,
order: cat.order,
updatedAt: cat.updatedAt,
})
.where(and(eq(categories.id, cat.id), eq(categories.userId, userId)));
} else {
await tx.insert(categories).values({
...cat,
userId,
});
}
}
}
// Process tasks
if (data.changes.tasks && data.changes.tasks.length > 0) {
for (const task of data.changes.tasks) {
const existing = await tx
.select()
.from(tasks)
.where(and(eq(tasks.id, task.id), eq(tasks.userId, userId)))
.limit(1);
if (existing.length > 0) {
// Check for conflict
if (existing[0].updatedAt > task.updatedAt) {
conflicts.push({
entity: 'tasks',
id: task.id,
serverVersion: existing[0],
clientVersion: task,
resolution: 'server_wins',
});
continue; // Server wins
}
// Prevent completing tasks with future due dates
if (task.completed === true) {
const effectiveDueDate = task.dueDate ?? existing[0].dueDate;
if (!canCompleteTask(effectiveDueDate)) {
conflicts.push({
entity: 'tasks',
id: task.id,
serverVersion: existing[0],
clientVersion: task,
resolution: 'server_wins',
});
continue;
}
}
await tx
.update(tasks)
.set({
title: task.title,
description: task.description,
categoryId: task.categoryId,
priority: task.priority,
completed: task.completed,
dueDate: task.dueDate,
dueTime: task.dueTime ?? '',
endTime: task.endTime ?? '',
allDay: task.allDay ?? false,
repeat: task.repeat ?? 'none',
repeatInterval: task.repeatInterval ?? 1,
repeatDays: task.repeatDays ?? '',
seriesId: task.seriesId ?? '',
reminder: task.reminder ?? 'none',
reminders: task.reminders ?? '',
assigneeId: task.assigneeId ?? null,
updatedAt: task.updatedAt,
})
.where(and(eq(tasks.id, task.id), eq(tasks.userId, userId)));
} else {
await tx.insert(tasks).values({
...task,
allDay: task.allDay ?? false,
userId,
});
}
}
}
// Process subtasks
if (data.changes.subtasks && data.changes.subtasks.length > 0) {
for (const sub of data.changes.subtasks) {
const existing = await tx
.select()
.from(subtasks)
.where(and(eq(subtasks.id, sub.id), eq(subtasks.userId, userId)))
.limit(1);
if (existing.length > 0) {
// Check for conflict
if (existing[0].updatedAt > sub.updatedAt) {
conflicts.push({
entity: 'subtasks',
id: sub.id,
serverVersion: existing[0],
clientVersion: sub,
resolution: 'server_wins',
});
continue; // Server wins
}
await tx
.update(subtasks)
.set({
taskId: sub.taskId,
title: sub.title,
description: sub.description ?? '',
priority: sub.priority ?? 'none',
completed: sub.completed,
dueDate: sub.dueDate ?? 0,
dueTime: sub.dueTime ?? '',
endTime: sub.endTime ?? '',
allDay: sub.allDay ?? false,
repeat: sub.repeat ?? 'none',
repeatInterval: sub.repeatInterval ?? 1,
repeatDays: sub.repeatDays ?? '',
seriesId: sub.seriesId ?? '',
reminder: sub.reminder ?? 'none',
reminders: sub.reminders ?? '',
assigneeId: sub.assigneeId ?? null,
order: sub.order,
updatedAt: sub.updatedAt,
})
.where(and(eq(subtasks.id, sub.id), eq(subtasks.userId, userId)));
} else {
await tx.insert(subtasks).values({
...sub,
userId,
});
}
}
}
// Process repeat profiles
if (data.changes.repeatProfiles && data.changes.repeatProfiles.length > 0) {
for (const profile of data.changes.repeatProfiles) {
const existing = await tx
.select()
.from(repeatProfiles)
.where(and(eq(repeatProfiles.id, profile.id), eq(repeatProfiles.userId, userId)))
.limit(1);
if (existing.length > 0) {
// Check for conflict
if (existing[0].updatedAt > profile.updatedAt) {
conflicts.push({
entity: 'repeatProfiles',
id: profile.id,
serverVersion: existing[0],
clientVersion: profile,
resolution: 'server_wins',
});
continue; // Server wins
}
await tx
.update(repeatProfiles)
.set({
name: profile.name,
repeat: profile.repeat,
repeatInterval: profile.repeatInterval,
repeatDays: profile.repeatDays,
updatedAt: profile.updatedAt,
})
.where(and(eq(repeatProfiles.id, profile.id), eq(repeatProfiles.userId, userId)));
} else {
await tx.insert(repeatProfiles).values({
...profile,
userId,
});
}
}
}
// Process friendships
if (data.changes.friendships && data.changes.friendships.length > 0) {
for (const friendship of data.changes.friendships) {
const existing = await tx
.select()
.from(friendships)
.where(eq(friendships.id, friendship.id))
.limit(1);
if (existing.length > 0) {
// Check for conflict
if (existing[0].updatedAt > friendship.updatedAt) {
conflicts.push({
entity: 'friendships',
id: friendship.id,
serverVersion: existing[0],
clientVersion: friendship,
resolution: 'server_wins',
});
continue; // Server wins
}
await tx
.update(friendships)
.set({
userId: friendship.userId,
friendId: friendship.friendId,
status: friendship.status,
updatedAt: friendship.updatedAt,
})
.where(eq(friendships.id, friendship.id));
} else {
await tx.insert(friendships).values(friendship);
}
}
}
});
} catch (error) {
console.error('Sync push error:', error);
throw new AppError('SERVER_ERROR', 'Failed to process sync push', 500);
}
res.json({
success: true,
timestamp,
conflicts,
});
}));
export default router;
+326
View File
@@ -0,0 +1,326 @@
import { Router, Request, Response } from 'express';
import { asyncHandler } from '../utils/asyncHandler';
import { db } from '../db';
import { tasks, subtasks, categories } from '../db/schema';
import { eq, and, desc, asc, gte, lte, inArray, sql } from 'drizzle-orm';
import { authMiddleware } from '../utils/auth';
import { AppError } from '../middleware/errorHandler';
import { taskCreateSchema, taskUpdateSchema, taskQuerySchema, canCompleteTask } from '../utils/validation';
import { z } from 'zod';
const router = Router();
router.use(authMiddleware);
function applyTaskFilters(query: any, userId: string, filters: any) {
const conditions = [eq(tasks.userId, userId)];
if (filters.categoryId) {
conditions.push(eq(tasks.categoryId, filters.categoryId));
}
if (filters.completed !== undefined) {
conditions.push(eq(tasks.completed, filters.completed));
}
if (filters.dueBefore) {
conditions.push(lte(tasks.dueDate, filters.dueBefore));
}
if (filters.dueAfter) {
conditions.push(gte(tasks.dueDate, filters.dueAfter));
}
if (filters.priority) {
conditions.push(eq(tasks.priority, filters.priority));
}
return query.where(and(...conditions));
}
function applyTaskSorting(query: any, sortBy: string = 'dueDate', sortOrder: string = 'asc') {
const orderFn = sortOrder === 'desc' ? desc : asc;
const columnMap: Record<string, any> = {
dueDate: tasks.dueDate,
priority: tasks.priority,
createdAt: tasks.createdAt,
title: tasks.title,
};
return query.orderBy(orderFn(columnMap[sortBy] || tasks.dueDate));
}
router.get('/', asyncHandler(async (req: Request, res: Response) => {
const filters = taskQuerySchema.parse(req.query);
const userId = req.user!.userId;
let query: any = db.select().from(tasks);
query = applyTaskFilters(query, userId, filters);
query = applyTaskSorting(query, filters.sortBy, filters.sortOrder);
const limit = filters.limit ?? 50;
const offset = filters.offset ?? 0;
query = query.limit(limit).offset(offset);
const results = await query;
// Fetch subtasks for each task
const taskIds = results.map((t: { id: string }) => t.id);
let taskSubtasks: any[] = [];
if (taskIds.length > 0) {
taskSubtasks = await db
.select()
.from(subtasks)
.where(and(eq(subtasks.userId, userId), inArray(subtasks.taskId, taskIds)))
.orderBy(asc(subtasks.order));
}
const subtasksByTask = taskSubtasks.reduce((acc, st) => {
if (!acc[st.taskId]) acc[st.taskId] = [];
acc[st.taskId].push(st);
return acc;
}, {} as Record<string, any[]>);
const tasksWithSubtasks = results.map((task: any) => ({
...task,
subtasks: subtasksByTask[task.id] || [],
}));
// Get total count
const countQuery: any = db.select({ count: sql`count(*)` }).from(tasks);
const countResult = await applyTaskFilters(countQuery, userId, filters);
const total = Number(countResult[0]?.count ?? 0);
res.json({ tasks: tasksWithSubtasks, total, limit, offset });
}));
router.get('/:id', asyncHandler(async (req: Request, res: Response) => {
const userId = req.user!.userId;
const task = await db
.select()
.from(tasks)
.where(and(eq(tasks.id, req.params.id), eq(tasks.userId, userId)))
.limit(1);
if (task.length === 0) {
throw new AppError('NOT_FOUND', 'Task not found', 404);
}
const taskSubtasks = await db
.select()
.from(subtasks)
.where(and(eq(subtasks.taskId, req.params.id), eq(subtasks.userId, userId)))
.orderBy(asc(subtasks.order));
res.json({ ...task[0], subtasks: taskSubtasks });
}));
router.post('/', asyncHandler(async (req: Request, res: Response) => {
const data = taskCreateSchema.parse(req.body);
const userId = req.user!.userId;
const now = Date.now();
// Verify category exists and belongs to user
const cat = await db
.select()
.from(categories)
.where(and(eq(categories.id, data.categoryId), eq(categories.userId, userId)))
.limit(1);
if (cat.length === 0) {
throw new AppError('NOT_FOUND', 'Category not found', 404);
}
const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
const newTask = {
id: taskId,
userId,
categoryId: data.categoryId,
title: data.title,
description: data.description ?? '',
priority: data.priority ?? 'none',
completed: false,
dueDate: data.dueDate ?? 0,
dueTime: data.dueTime ?? '',
endTime: data.endTime ?? '',
allDay: data.allDay ?? false,
assigneeId: data.assigneeId ?? null,
reminder: data.reminder ?? 'none',
reminders: data.reminders ?? '',
createdAt: now,
updatedAt: now,
};
await db.insert(tasks).values(newTask);
// Create subtasks if provided
if (data.subtasks && data.subtasks.length > 0) {
const subtaskValues = data.subtasks.map((st, index) => ({
id: `sub_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}${index}`,
userId,
taskId,
title: st.title,
completed: false,
order: index,
createdAt: now,
updatedAt: now,
}));
await db.insert(subtasks).values(subtaskValues);
}
const createdSubtasks = await db
.select()
.from(subtasks)
.where(and(eq(subtasks.taskId, taskId), eq(subtasks.userId, userId)))
.orderBy(asc(subtasks.order));
res.status(201).json({ ...newTask, subtasks: createdSubtasks });
}));
router.patch('/:id', asyncHandler(async (req: Request, res: Response) => {
const data = taskUpdateSchema.parse(req.body);
const userId = req.user!.userId;
const existing = await db
.select()
.from(tasks)
.where(and(eq(tasks.id, req.params.id), eq(tasks.userId, userId)))
.limit(1);
if (existing.length === 0) {
throw new AppError('NOT_FOUND', 'Task not found', 404);
}
// Verify category if provided
if (data.categoryId) {
const cat = await db
.select()
.from(categories)
.where(and(eq(categories.id, data.categoryId), eq(categories.userId, userId)))
.limit(1);
if (cat.length === 0) {
throw new AppError('NOT_FOUND', 'Category not found', 404);
}
}
// Prevent completing tasks with future due dates
if (data.completed === true) {
const effectiveDueDate = data.dueDate ?? existing[0].dueDate;
if (!canCompleteTask(effectiveDueDate)) {
throw new AppError('VALIDATION_ERROR', 'Cannot mark a task as completed if its due date is in the future', 400);
}
}
const now = Date.now();
const updated = await db
.update(tasks)
.set({ ...data, updatedAt: now })
.where(and(eq(tasks.id, req.params.id), eq(tasks.userId, userId)))
.returning();
const taskSubtasks = await db
.select()
.from(subtasks)
.where(and(eq(subtasks.taskId, req.params.id), eq(subtasks.userId, userId)))
.orderBy(asc(subtasks.order));
res.json({ ...updated[0], subtasks: taskSubtasks });
}));
router.delete('/:id', asyncHandler(async (req: Request, res: Response) => {
const userId = req.user!.userId;
const existing = await db
.select()
.from(tasks)
.where(and(eq(tasks.id, req.params.id), eq(tasks.userId, userId)))
.limit(1);
if (existing.length === 0) {
throw new AppError('NOT_FOUND', 'Task not found', 404);
}
await db
.delete(tasks)
.where(and(eq(tasks.id, req.params.id), eq(tasks.userId, userId)));
res.status(204).send();
}));
router.post('/batch', asyncHandler(async (req: Request, res: Response) => {
const { operations } = z.object({
operations: z.array(
z.union([
z.object({ type: z.literal('create'), data: taskCreateSchema }),
z.object({ type: z.literal('update'), id: z.string(), data: taskUpdateSchema }),
z.object({ type: z.literal('delete'), id: z.string() }),
])
),
}).parse(req.body);
const userId = req.user!.userId;
const results: any[] = [];
for (const op of operations) {
try {
if (op.type === 'create') {
const cat = await db
.select()
.from(categories)
.where(and(eq(categories.id, op.data.categoryId), eq(categories.userId, userId)))
.limit(1);
if (cat.length === 0) throw new AppError('NOT_FOUND', 'Category not found', 404);
const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
const now = Date.now();
await db.insert(tasks).values({
id: taskId,
userId,
...op.data,
completed: false,
createdAt: now,
updatedAt: now,
});
results.push({ id: taskId, success: true });
} else if (op.type === 'update') {
if (op.data.completed === true) {
let effectiveDueDate: number;
if (op.data.dueDate !== undefined) {
effectiveDueDate = op.data.dueDate;
} else {
const existingTask = await db
.select({ dueDate: tasks.dueDate })
.from(tasks)
.where(and(eq(tasks.id, op.id), eq(tasks.userId, userId)))
.limit(1);
if (existingTask.length === 0) throw new AppError('NOT_FOUND', 'Task not found', 404);
effectiveDueDate = existingTask[0].dueDate;
}
if (!canCompleteTask(effectiveDueDate)) {
throw new AppError('VALIDATION_ERROR', 'Cannot mark a task as completed if its due date is in the future', 400);
}
}
await db
.update(tasks)
.set({ ...op.data, updatedAt: Date.now() })
.where(and(eq(tasks.id, op.id), eq(tasks.userId, userId)));
results.push({ id: op.id, success: true });
} else if (op.type === 'delete') {
await db
.delete(tasks)
.where(and(eq(tasks.id, op.id), eq(tasks.userId, userId)));
results.push({ id: op.id, success: true });
}
} catch (error) {
results.push({
id: 'id' in op ? op.id : 'unknown',
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
});
}
}
res.json({ results });
}));
export default router;
+111
View File
@@ -0,0 +1,111 @@
import { Router, Request, Response } from 'express';
import { asyncHandler } from '../utils/asyncHandler';
import { db } from '../db';
import { users, userSettings, categories } from '../db/schema';
import { eq, and, ilike } from 'drizzle-orm';
import { authMiddleware } from '../utils/auth';
import { AppError } from '../middleware/errorHandler';
import { userSettingsSchema } from '../utils/validation';
const router = Router();
router.use(authMiddleware);
router.get('/me', asyncHandler(async (req: Request, res: Response) => {
const userId = req.user!.userId;
const user = await db.select().from(users).where(eq(users.id, userId)).limit(1);
if (user.length === 0) {
throw new AppError('NOT_FOUND', 'User not found', 404);
}
const settings = await db
.select()
.from(userSettings)
.where(eq(userSettings.userId, userId))
.limit(1);
let defaultCategory = settings[0]?.defaultCategory;
if (defaultCategory) {
const cat = await db
.select()
.from(categories)
.where(and(eq(categories.id, defaultCategory), eq(categories.userId, userId)))
.limit(1);
if (cat.length === 0) {
defaultCategory = null;
}
}
res.json({
id: user[0].id,
username: user[0].username,
settings: {
darkMode: settings[0]?.darkMode ?? false,
notifications: settings[0]?.notifications ?? true,
reminderTime: settings[0]?.reminderTime ?? '09:00',
defaultCategory,
sortBy: settings[0]?.sortBy ?? 'dueDate',
sortOrder: settings[0]?.sortOrder ?? 'asc',
},
createdAt: user[0].createdAt,
});
}));
router.patch('/me/settings', asyncHandler(async (req: Request, res: Response) => {
const data = userSettingsSchema.parse(req.body);
const userId = req.user!.userId;
// Validate defaultCategory if provided
if (data.defaultCategory) {
const cat = await db
.select()
.from(categories)
.where(and(eq(categories.id, data.defaultCategory), eq(categories.userId, userId)))
.limit(1);
if (cat.length === 0) {
throw new AppError('NOT_FOUND', 'Default category not found', 404);
}
}
const now = Date.now();
const existing = await db
.select()
.from(userSettings)
.where(eq(userSettings.userId, userId))
.limit(1);
if (existing.length === 0) {
await db.insert(userSettings).values({
userId,
...data,
updatedAt: now,
});
} else {
await db
.update(userSettings)
.set({ ...data, updatedAt: now })
.where(eq(userSettings.userId, userId));
}
res.status(200).json({ success: true });
}));
router.get('/search', asyncHandler(async (req: Request, res: Response) => {
const { q } = req.query;
const userId = req.user!.userId;
if (!q || typeof q !== 'string' || q.length < 2) {
return res.json([]);
}
const results = await db
.select({ id: users.id, username: users.username })
.from(users)
.where(and(ilike(users.username, `%${q}%`), eq(users.id, userId)))
.limit(10);
res.json(results);
}));
export default router;