Skip to content
Merged
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
35 changes: 34 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
"build": "tsc",
"start": "node dist/server.js",
"test": "vitest",
"test:run": "vitest run"
"test:run": "vitest run",
"cli": "tsx cli/index.ts",
"dashboard": "npm run dev & cd dashboard && npm run dev"
},
"keywords": [],
"author": "",
Expand All @@ -20,7 +22,8 @@
"dotenv": "^17.4.2",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"pg": "^8.21.0"
"pg": "^8.21.0",
"ws": "^8.21.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
Expand All @@ -29,6 +32,7 @@
"@types/jsonwebtoken": "^9.0.10",
"@types/pg": "^8.20.0",
"@types/supertest": "^7.2.0",
"@types/ws": "^8.18.1",
"supertest": "^7.2.2",
"tsx": "^4.22.4",
"typescript": "^6.0.3",
Expand Down
6 changes: 6 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import express from "express"
import cors from "cors"
import { notFound } from "./middleware/notFound.js"
import { errorHandler } from "./middleware/errorHandler.js"
import healthRouter from './routes/health.js'
import pingRouter from './routes/ping.js'
import authRouter from './routes/auth.js'
import cardRouter from './routes/card.js'
import labelRouter from './routes/label.js'

export const app = express()

app.use(cors())
app.use(express.json())
app.use('/health', healthRouter)
app.use('/api/ping', pingRouter)
app.use('/auth', authRouter)
app.use('/api', cardRouter)
app.use('/api', labelRouter)
app.use(notFound)
app.use(errorHandler)

38 changes: 38 additions & 0 deletions src/repositories/activityLogRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { pool } from '../db/pool.js'

export interface ActivityLogEntry {
id: string
actorId: string
action: string
entityType: string
entityId: string
metadata: Record<string, unknown>
createdAt: Date
}

export async function logActivity(
actorId: string,
action: string,
entityType: string,
entityId: string,
metadata: Record<string, unknown> = {}
): Promise<ActivityLogEntry> {
const result = await pool.query(
`INSERT INTO activity_log (actor_id, action, entity_type, entity_id, metadata)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[actorId, action, entityType, entityId, JSON.stringify(metadata)]
)
return result.rows[0]
}

export async function getActivityForEntity(
entityType: string,
entityId: string
): Promise<ActivityLogEntry[]> {
const result = await pool.query(
'SELECT * FROM activity_log WHERE entity_type = $1 AND entity_id = $2 ORDER BY created_at DESC',
[entityType, entityId]
)
return result.rows
}
138 changes: 138 additions & 0 deletions src/repositories/cardRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { pool } from '../db/pool.js'

export interface Card {
id: string
listId: string
title: string
description: string
position: number
dueDate: Date | null
priority: 'low' | 'medium' | 'high' | 'urgent'
version: number
createdAt: Date
updatedAt: Date
}

function mapCard(row: any): Card {
return {
id: row.id,
listId: row.list_id,
title: row.title,
description: row.description,
position: row.position,
dueDate: row.due_date ?? null,
priority: row.priority,
version: row.version,
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}

export async function createCard(
listId: string,
title: string,
description: string,
priority: Card['priority']
): Promise<Card> {
const result = await pool.query(
`INSERT INTO cards (list_id, title, description, position, priority)
VALUES ($1, $2, $3, COALESCE((SELECT MAX(position) + 1 FROM cards WHERE list_id = $1), 0), $4)
RETURNING *`,
[listId, title, description, priority]
)
return mapCard(result.rows[0])
}

export async function findById(id: string): Promise<Card | undefined> {
const result = await pool.query('SELECT * FROM cards WHERE id = $1', [id])
return result.rows[0] ? mapCard(result.rows[0]) : undefined
}

export async function findCardsByListId(listId: string): Promise<Card[]> {
const result = await pool.query(
'SELECT * FROM cards WHERE list_id = $1 ORDER BY position ASC',
[listId]
)
return result.rows.map(mapCard)
}

export async function updateCard(
id: string,
fields: { title?: string; description?: string; priority?: Card['priority']; dueDate?: Date | null },
expectedVersion: number
): Promise<Card | undefined> {
const setClauses: string[] = []
const values: any[] = []
let paramIndex = 1

if (fields.title !== undefined) {
setClauses.push(`title = $${paramIndex++}`)
values.push(fields.title)
}
if (fields.description !== undefined) {
setClauses.push(`description = $${paramIndex++}`)
values.push(fields.description)
}
if (fields.priority !== undefined) {
setClauses.push(`priority = $${paramIndex++}`)
values.push(fields.priority)
}
if (fields.dueDate !== undefined) {
setClauses.push(`due_date = $${paramIndex++}`)
values.push(fields.dueDate)
}

if (setClauses.length === 0) return await findById(id)

setClauses.push(`updated_at = now()`)
setClauses.push(`version = version + 1`)

values.push(id, expectedVersion)
const result = await pool.query(
`UPDATE cards SET ${setClauses.join(', ')} WHERE id = $${paramIndex++} AND version = $${paramIndex++}
RETURNING *`,
values
)
return result.rows[0] ? mapCard(result.rows[0]) : undefined
}

export async function deleteCard(id: string): Promise<boolean> {
const result = await pool.query('DELETE FROM cards WHERE id = $1', [id])
return (result.rowCount ?? 0) > 0
}

export async function searchCards(query: string, limit = 20): Promise<Card[]> {
const result = await pool.query(
`SELECT * FROM cards
WHERE search_vector @@ plainto_tsquery('spanish', $1)
ORDER BY ts_rank(search_vector, plainto_tsquery('spanish', $1)) DESC
LIMIT $2`,
[query, limit]
)
return result.rows.map(mapCard)
}

export async function moveCard(
id: string,
targetListId: string,
newPosition: number,
expectedVersion: number
): Promise<Card | undefined> {
await pool.query(
`UPDATE cards SET position = position - 1 WHERE list_id = (SELECT list_id FROM cards WHERE id = $1) AND position > (SELECT position FROM cards WHERE id = $1)`,
[id]
)

await pool.query(
`UPDATE cards SET position = position + 1 WHERE list_id = $2 AND position >= $3 AND id != $1`,
[id, targetListId, newPosition]
)

const result = await pool.query(
`UPDATE cards SET list_id = $2, position = $3, version = version + 1, updated_at = now()
WHERE id = $1 AND version = $4
RETURNING *`,
[id, targetListId, newPosition, expectedVersion]
)
return result.rows[0] ? mapCard(result.rows[0]) : undefined
}
34 changes: 34 additions & 0 deletions src/repositories/labelRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { pool } from '../db/pool.js'

export interface Label {
id: string
name: string
color: string
}

export async function createLabel(name: string, color: string): Promise<Label> {
const result = await pool.query(
'INSERT INTO labels (name, color) VALUES ($1, $2) RETURNING *',
[name, color]
)
return result.rows[0]
}

export async function findAllLabels(): Promise<Label[]> {
const result = await pool.query('SELECT * FROM labels ORDER BY name ASC')
return result.rows
}

export async function addLabelToCard(cardId: string, labelId: string): Promise<void> {
await pool.query(
'INSERT INTO card_labels (card_id, label_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
[cardId, labelId]
)
}

export async function removeLabelFromCard(cardId: string, labelId: string): Promise<void> {
await pool.query(
'DELETE FROM card_labels WHERE card_id = $1 AND label_id = $2',
[cardId, labelId]
)
}
60 changes: 60 additions & 0 deletions src/routes/card.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Router, type Request, type Response } from 'express'
import { createCardInList, getCardsByListId, getCardById, updateCardById, deleteCardById, moveCardToList, searchCardsByQuery } from '../services/card.js'
import { authMiddleware } from '../middleware/auth.js'

const router = Router()

router.post('/lists/:listId/cards', authMiddleware, async (req: Request, res: Response) => {
const { listId } = req.params
const { title, description, priority } = req.body
const userId = (req as any).userId

const card = await createCardInList(listId, title, description, priority ?? 'medium', userId)
res.status(201).json(card)
})

router.get('/lists/:listId/cards', authMiddleware, async (req: Request, res: Response) => {
const { listId } = req.params
const cards = await getCardsByListId(listId)
res.json(cards)
})

router.get('/cards/search', authMiddleware, async (req: Request, res: Response) => {
const query = req.query.q as string
const cards = await searchCardsByQuery(query)
res.json(cards)
})

router.get('/cards/:id', authMiddleware, async (req: Request, res: Response) => {
const { id } = req.params
const card = await getCardById(id)
res.json(card)
})

router.patch('/cards/:id', authMiddleware, async (req: Request, res: Response) => {
const { id } = req.params
const { title, description, priority, dueDate, expectedVersion } = req.body
const userId = (req as any).userId

const card = await updateCardById(id, { title, description, priority, dueDate }, userId, expectedVersion)
res.json(card)
})

router.delete('/cards/:id', authMiddleware, async (req: Request, res: Response) => {
const { id } = req.params
const userId = (req as any).userId

await deleteCardById(id, userId)
res.status(204).send()
})

router.post('/cards/:id/move', authMiddleware, async (req: Request, res: Response) => {
const { id } = req.params
const { targetListId, newPosition, expectedVersion } = req.body
const userId = (req as any).userId

const card = await moveCardToList(id, targetListId, newPosition, userId, expectedVersion)
res.json(card)
})

export default router
Loading
Loading