From fab05f32ec2afed001c25463b3dc312c5ae31c09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oriol=20G=C3=B3mez?= Date: Sat, 13 Sep 2025 07:45:19 +0200 Subject: [PATCH] feat: implement check functionality --- AGENTS.md | 40 +++++++++ backend/migrations/3_checked.sql | 3 + backend/schema.sql | 25 +++--- backend/src/controllers/message-controller.ts | 19 +++- backend/src/routes/message.ts | 11 +-- backend/src/services/message-service.ts | 59 ++++++------ backend/types.ts | 15 ++-- .../src/components/chat/InputActions.vue | 19 +++- .../src/components/chat/MessageInput.vue | 4 +- .../src/components/chat/MessageItem.vue | 90 +++++++++++++++++-- frontend-vue/src/services/api.ts | 9 +- frontend-vue/src/services/sync.ts | 1 + frontend-vue/src/stores/app.ts | 5 ++ frontend-vue/src/types/index.ts | 3 +- frontend-vue/src/views/MainView.vue | 16 +++- 15 files changed, 258 insertions(+), 61 deletions(-) create mode 100644 AGENTS.md create mode 100644 backend/migrations/3_checked.sql diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1ef56f7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,40 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- `frontend-vue/` – Vue 3 + Vite app. Key folders: `src/components`, `src/views`, `src/stores`, `src/services`, `src/composables`, `src/router`, and static assets in `public/`. +- `backend/` – TypeScript API + WebSocket server. Key folders: `src/controllers`, `src/routes`, `src/services`, `src/utils`, `src/logging`, `src/jobs`. Database files in `schema.sql` and `migrations/`. +- `etc/systemd/` – example unit files for deployment. `dockerfile` – container build (optional). + +## Build, Test, and Development Commands +- Frontend + - `cd frontend-vue && npm install` + - `npm run dev` – start Vite dev server. + - `npm run build` – type-check + production build. + - `npm run preview` – preview production build. + - `npm run lint` / `npm run format` – ESLint / Prettier. +- Backend + - `cd backend && npm install` + - `npm run dev` – start server with `tsx --watch`. + - `npm run start` – start server once (prod-like). + - Note: backend was initialized with Bun; Node/npm + tsx is the primary flow. + +## Coding Style & Naming Conventions +- General: TypeScript, 2-space indent, small focused modules. File names: lowercase-kebab for modules (e.g., `message-service.ts`), `PascalCase.vue` for Vue SFCs. +- Frontend: Prettier + ESLint enforced; prefer single quotes; composition API; component names in `PascalCase`. +- Backend: Keep semicolons and consistent import quoting (matches current files). Use `PascalCase` for types/classes, `camelCase` for variables/functions. + +## Testing Guidelines +- No formal test runner is configured yet. If adding tests: + - Place unit tests alongside code as `*.spec.ts` or in a `__tests__/` directory. + - Keep tests fast and deterministic; document manual verification steps in PRs until a runner is introduced. + +## Commit & Pull Request Guidelines +- Commits: imperative mood, concise subject (<=72 chars), include scope prefix when helpful, e.g. `frontend: fix message input blur` or `backend: add channel search`. +- PRs must include: clear description, linked issue (if any), screenshots/GIFs for UI changes, manual test steps, and notes on migrations if touching `backend/migrations/`. +- Ensure `npm run lint` (frontend) passes and the app runs locally for both services before requesting review. + +## Security & Configuration Tips +- Backend reads environment from `.env` (see `backend/src/config.ts`): important keys include `DB_PATH`, `API_TOKEN`, `UPLOAD_DIR`, `PORT`, `USE_SSL`, `OPENAI_API_KEY`, `OLLAMA_URL`, and related model settings. +- Do not commit secrets. Provide `.env` examples in PRs when adding new variables. +- For local SSL, set `USE_SSL=1` and supply `SSL_KEY/SSL_CERT`, or let the server generate a self-signed pair for development. + diff --git a/backend/migrations/3_checked.sql b/backend/migrations/3_checked.sql new file mode 100644 index 0000000..d245f0e --- /dev/null +++ b/backend/migrations/3_checked.sql @@ -0,0 +1,3 @@ +-- Add tri-state checked column to messages (NULL | 0 | 1) +ALTER TABLE messages ADD COLUMN checked INTEGER NULL; + diff --git a/backend/schema.sql b/backend/schema.sql index 48077a7..46d89cf 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -13,19 +13,20 @@ CREATE TABLE IF NOT EXISTS files ( createdAt DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (channelId) REFERENCES channels (id) ON DELETE CASCADE ); -CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - channelId INTEGER, - content TEXT, - fileId INTEGER NULL, - createdAt DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (channelId) REFERENCES channels (id) ON DELETE CASCADE, - FOREIGN KEY (fileId) REFERENCES files (id) ON DELETE - SET - NULL -); +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channelId INTEGER, + content TEXT, + fileId INTEGER NULL, + checked INTEGER NULL, + createdAt DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (channelId) REFERENCES channels (id) ON DELETE CASCADE, + FOREIGN KEY (fileId) REFERENCES files (id) ON DELETE + SET + NULL +); CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( content, content = 'messages', content_rowid = 'id' -); \ No newline at end of file +); diff --git a/backend/src/controllers/message-controller.ts b/backend/src/controllers/message-controller.ts index 08eceb3..21593c3 100644 --- a/backend/src/controllers/message-controller.ts +++ b/backend/src/controllers/message-controller.ts @@ -53,7 +53,7 @@ export const getMessages = async (req: Request, res: Response) => { res.json({ messages }); } -export const moveMessage = async (req: Request, res: Response) => { +export const moveMessage = async (req: Request, res: Response) => { const { messageId } = req.params; const { targetChannelId } = req.body; @@ -77,4 +77,19 @@ export const moveMessage = async (req: Request, res: Response) => { logger.critical(`Failed to move message ${messageId}:`, error); res.status(500).json({ error: 'Failed to move message' }); } -} \ No newline at end of file +} + +export const setChecked = async (req: Request, res: Response) => { + const { messageId } = req.params; + const { checked } = req.body as { checked: boolean | null | undefined }; + if (!messageId) { + return res.status(400).json({ error: 'Message ID is required' }); + } + const value = (checked === undefined) ? null : checked; + const result = await MessageService.setMessageChecked(messageId, value); + if (result.changes === 0) { + return res.status(404).json({ error: 'Message not found' }); + } + logger.info(`Message ${messageId} checked set to ${value}`); + res.json({ id: parseInt(messageId), checked: value }); +} diff --git a/backend/src/routes/message.ts b/backend/src/routes/message.ts index 4fb5842..ad4ff25 100644 --- a/backend/src/routes/message.ts +++ b/backend/src/routes/message.ts @@ -4,9 +4,10 @@ import { authenticate } from '../middleware/auth'; export const router = Router({mergeParams: true}); -router.post('/', authenticate, MessageController.createMessage); -router.put('/:messageId', authenticate, MessageController.updateMessage); -router.put('/:messageId/move', authenticate, MessageController.moveMessage); -router.delete('/:messageId', authenticate, MessageController.deleteMessage); -router.get('/', authenticate, MessageController.getMessages); +router.post('/', authenticate, MessageController.createMessage); +router.put('/:messageId', authenticate, MessageController.updateMessage); +router.put('/:messageId/move', authenticate, MessageController.moveMessage); +router.put('/:messageId/checked', authenticate, MessageController.setChecked); +router.delete('/:messageId', authenticate, MessageController.deleteMessage); +router.get('/', authenticate, MessageController.getMessages); diff --git a/backend/src/services/message-service.ts b/backend/src/services/message-service.ts index 1041b0a..ceb9e94 100644 --- a/backend/src/services/message-service.ts +++ b/backend/src/services/message-service.ts @@ -1,9 +1,9 @@ import { db, FTS5Enabled } from "../db"; import { events } from "../globals"; -export const createMessage = async (channelId: string, content: string) => { - const query = db.prepare(`INSERT INTO messages (channelId, content) VALUES ($channelId, $content)`); - const result = query.run({ channelId: channelId, content: content }); +export const createMessage = async (channelId: string, content: string) => { + const query = db.prepare(`INSERT INTO messages (channelId, content, checked) VALUES ($channelId, $content, NULL)`); + const result = query.run({ channelId: channelId, content: content }); const messageId = result.lastInsertRowid; console.log(`Adding message for search with id ${messageId}`); @@ -17,9 +17,9 @@ export const createMessage = async (channelId: string, content: string) => { return messageId; } -export const updateMessage = async (messageId: string, content: string, append: boolean = false) => { - const query = db.prepare(`UPDATE messages SET content = $content WHERE id = $id`); - const result = query.run({ content: content, id: messageId }); +export const updateMessage = async (messageId: string, content: string, append: boolean = false) => { + const query = db.prepare(`UPDATE messages SET content = $content WHERE id = $id`); + const result = query.run({ content: content, id: messageId }); @@ -46,13 +46,13 @@ export const deleteMessage = async (messageId: string) => { return result; } -export const getMessages = async (channelId: string) => { - const query = db.prepare(` - SELECT - messages.id, messages.channelId, messages.content, messages.createdAt, - files.id as fileId, files.filePath, files.fileType, files.createdAt as fileCreatedAt, files.originalName, files.fileSize - FROM - messages +export const getMessages = async (channelId: string) => { + const query = db.prepare(` + SELECT + messages.id, messages.channelId, messages.content, messages.createdAt, messages.checked, + files.id as fileId, files.filePath, files.fileType, files.createdAt as fileCreatedAt, files.originalName, files.fileSize + FROM + messages LEFT JOIN files ON @@ -61,16 +61,16 @@ export const getMessages = async (channelId: string) => { messages.channelId = $channelId `); const rows = query.all({ channelId: channelId }); - return rows; -} + return rows; +} -export const getMessage = async (id: string) => { - const query = db.prepare(` - SELECT - messages.id, messages.channelId, messages.content, messages.createdAt, - files.id as fileId, files.filePath, files.fileType, files.createdAt as fileCreatedAt, files.originalName, files.fileSize - FROM - messages +export const getMessage = async (id: string) => { + const query = db.prepare(` + SELECT + messages.id, messages.channelId, messages.content, messages.createdAt, messages.checked, + files.id as fileId, files.filePath, files.fileType, files.createdAt as fileCreatedAt, files.originalName, files.fileSize + FROM + messages LEFT JOIN files ON @@ -79,8 +79,17 @@ export const getMessage = async (id: string) => { messages.id = $id `); const row = query.get({ id: id }); - return row; -} + return row; +} + +export const setMessageChecked = async (messageId: string, checked: boolean | null) => { + const query = db.prepare(`UPDATE messages SET checked = $checked WHERE id = $id`); + // SQLite stores booleans as integers; NULL for unknown + const value = checked === null ? null : (checked ? 1 : 0); + const result = query.run({ id: messageId, checked: value }); + events.emit('message-updated', messageId, { checked: value }); + return result; +} export const moveMessage = async (messageId: string, targetChannelId: string) => { // Get current message to emit proper events @@ -104,4 +113,4 @@ export const moveMessage = async (messageId: string, targetChannelId: string) => events.emit('message-moved', messageId, (currentMessage as any).channelId, targetChannelId); return result; -} \ No newline at end of file +} diff --git a/backend/types.ts b/backend/types.ts index 43ffd46..ab5b4ba 100644 --- a/backend/types.ts +++ b/backend/types.ts @@ -4,12 +4,13 @@ export interface Channel { created_at: string; } -export interface Message { - id: number; - channel_id: number; - content: string; - created_at: string; -} +export interface Message { + id: number; + channel_id: number; + content: string; + created_at: string; + checked?: boolean | null; +} export interface File { id: number; @@ -18,4 +19,4 @@ export interface File { file_path: string; file_type: string; created_at: string; -} \ No newline at end of file +} diff --git a/frontend-vue/src/components/chat/InputActions.vue b/frontend-vue/src/components/chat/InputActions.vue index 2889955..2c75cd1 100644 --- a/frontend-vue/src/components/chat/InputActions.vue +++ b/frontend-vue/src/components/chat/InputActions.vue @@ -30,6 +30,16 @@ 🎤 + + ✓ + + () @@ -70,4 +81,10 @@ defineEmits<{ gap: 0.25rem; /* Reduced gap to save space */ flex-shrink: 0; } - \ No newline at end of file + +/* Mobile-only for the checked toggle button */ +.input-actions [aria-label="Toggle check on focused message"] { display: none; } +@media (max-width: 480px) { + .input-actions [aria-label="Toggle check on focused message"] { display: inline-flex; } +} + diff --git a/frontend-vue/src/components/chat/MessageInput.vue b/frontend-vue/src/components/chat/MessageInput.vue index ff1a143..adb4b09 100644 --- a/frontend-vue/src/components/chat/MessageInput.vue +++ b/frontend-vue/src/components/chat/MessageInput.vue @@ -17,6 +17,7 @@ @file-upload="$emit('file-upload')" @camera="$emit('camera')" @voice="$emit('voice')" + @toggle-check="$emit('toggle-check')" @send="handleSubmit" /> @@ -35,6 +36,7 @@ const emit = defineEmits<{ 'file-upload': [] 'camera': [] 'voice': [] + 'toggle-check': [] }>() const appStore = useAppStore() @@ -120,4 +122,4 @@ defineExpose({ border-top-color: #374151; } } - \ No newline at end of file + diff --git a/frontend-vue/src/components/chat/MessageItem.vue b/frontend-vue/src/components/chat/MessageItem.vue index 5574e19..61cae1b 100644 --- a/frontend-vue/src/components/chat/MessageItem.vue +++ b/frontend-vue/src/components/chat/MessageItem.vue @@ -14,6 +14,8 @@ @focus="handleFocus" >
+ + {{ message.content }}
@@ -23,6 +25,16 @@
+
@@ -329,6 +330,19 @@ const setupKeyboardShortcuts = () => { }) } +const handleToggleCheckFocused = async () => { + const focused = messagesContainer.value?.getFocusedMessage?.() + if (!focused || 'channelId' in focused) return + try { + const next = (focused as ExtendedMessage).checked !== true + appStore.setMessageChecked((focused as ExtendedMessage).id, next) + await apiService.setMessageChecked((focused as ExtendedMessage).channel_id, (focused as ExtendedMessage).id, next) + toastStore.info(next ? 'Marked as checked' : 'Marked as unchecked') + } catch (e) { + toastStore.error('Failed to toggle check') + } +} + const selectChannel = async (channelId: number) => { console.log('Selecting channel:', channelId) await appStore.setCurrentChannel(channelId) @@ -763,4 +777,4 @@ onMounted(async () => { color: rgba(255, 255, 255, 0.87); } } - \ No newline at end of file +