From cf0f6373aa7c2cb0362b401c93fee6a01dc9a2d6 Mon Sep 17 00:00:00 2001 From: abdullah-dev5 Date: Wed, 16 Jul 2025 23:53:45 +0500 Subject: [PATCH 1/3] feat: implement Todo CRUD API with Labels (D7T1) --- app/controllers/LabelController.ts | 22 +++++++ app/controllers/TodoController.ts | 58 ++++++++++++++++++ app/models/label.ts | 44 +++++++++++++ app/models/todo.ts | 43 +++++++++++++ database/app.sqlite | Bin 0 -> 53248 bytes .../1752686416737_create_todos_table.ts | 25 ++++++++ .../1752688099236_create_labels_table.ts | 20 ++++++ .../1752691389977_create_label_todos_table.ts | 21 +++++++ database/seeders/label_seeder.ts | 14 +++++ database/seeders/todo_seeder.ts | 29 +++++++++ start/routes.ts | 40 +++++++++++- 11 files changed, 314 insertions(+), 2 deletions(-) create mode 100644 app/controllers/LabelController.ts create mode 100644 app/controllers/TodoController.ts create mode 100644 app/models/label.ts create mode 100644 app/models/todo.ts create mode 100644 database/app.sqlite create mode 100644 database/migrations/1752686416737_create_todos_table.ts create mode 100644 database/migrations/1752688099236_create_labels_table.ts create mode 100644 database/migrations/1752691389977_create_label_todos_table.ts create mode 100644 database/seeders/label_seeder.ts create mode 100644 database/seeders/todo_seeder.ts diff --git a/app/controllers/LabelController.ts b/app/controllers/LabelController.ts new file mode 100644 index 0000000..786fcd1 --- /dev/null +++ b/app/controllers/LabelController.ts @@ -0,0 +1,22 @@ +import { HttpContext } from '@adonisjs/core/http' +import Label from '#models/label' + +export default class LabelsController { + // List all labels + public async index({ }: HttpContext) { + return await Label.all() + } + + // Create a label + public async store({ request }: HttpContext) { + const data = request.only(['name', 'color']) + return await Label.create(data) + } + + // Delete a label + public async destroy({ params, response }: HttpContext) { + const label = await Label.findOrFail(params.id) + await label.delete() + return response.noContent() + } +} \ No newline at end of file diff --git a/app/controllers/TodoController.ts b/app/controllers/TodoController.ts new file mode 100644 index 0000000..aabf11f --- /dev/null +++ b/app/controllers/TodoController.ts @@ -0,0 +1,58 @@ +import { HttpContext } from '@adonisjs/core/http' +import Todo from '#models/todo' +import { DateTime } from 'luxon' + +export default class TodosController { + // Get all todos + public async index({ }: HttpContext) { + return await Todo.query() + .whereNull('deleted_at') + .preload('labels') + } + + // Get single todo + public async show({ params, response }: HttpContext) { + const todo = await Todo.query() + .where('id', params.id) + .whereNull('deleted_at') + .preload('labels') + .first() + + if (!todo) { + return response.notFound({ message: 'Todo not found' }) + } + + return todo + } + + // Create a todo + public async store({ request }: HttpContext) { + const { labelIds, ...data } = request.only(['title', 'description', 'isCompleted', 'labelIds']) + const todo = await Todo.create(data) + + if (labelIds) await todo.related('labels').attach(labelIds) + await todo.load('labels') + return todo + } + + // Update a todo + public async update({ params, request }: HttpContext) { + const todo = await Todo.findOrFail(params.id) + const { labelIds, ...data } = request.only(['title', 'description', 'isCompleted', 'labelIds']) + + todo.merge(data) + await todo.save() + + if (labelIds) await todo.related('labels').sync(labelIds) + await todo.load('labels') + return todo + } + + // Soft delete + public async destroy({ params, response }: HttpContext) { + const todo = await Todo.findOrFail(params.id) + todo.deletedAt = DateTime.now() + await todo.save() + return response.noContent() + } +} \ No newline at end of file diff --git a/app/models/label.ts b/app/models/label.ts new file mode 100644 index 0000000..f00a2b5 --- /dev/null +++ b/app/models/label.ts @@ -0,0 +1,44 @@ +import { BaseModel, column, belongsTo, manyToMany } from '@adonisjs/lucid/orm' +import type { BelongsTo, ManyToMany } from '@adonisjs/lucid/types/relations' +import Todo from '#models/todo' +import Note from '#models/note' +import User from '#models/user' +import { DateTime } from 'luxon' + +export default class Label extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare name: string + + @column() + declare color: string | null + + @column({ columnName: 'user_id' }) + declare userId: number + + // Relationship: Label belongs to a User + @belongsTo(() => User) + declare user: BelongsTo // Fixed import + + // Relationship: Label can be assigned to many Todos + @manyToMany(() => Todo, { + pivotTable: 'label_todo', + pivotTimestamps: true, + }) + declare todos: ManyToMany // Fixed import + + // Relationship: Label can be assigned to many Notes + @manyToMany(() => Note, { + pivotTable: 'label_note', + pivotTimestamps: true, + }) + declare notes: ManyToMany // Fixed import + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime | null +} \ No newline at end of file diff --git a/app/models/todo.ts b/app/models/todo.ts new file mode 100644 index 0000000..7019582 --- /dev/null +++ b/app/models/todo.ts @@ -0,0 +1,43 @@ +import { BaseModel, column, belongsTo, manyToMany } from '@adonisjs/lucid/orm' +import type { BelongsTo, ManyToMany } from '@adonisjs/lucid/types/relations' + +import User from './user.js' +import Label from './label.js' +import { DateTime } from 'luxon' + +export default class Todo extends BaseModel { + public static softDelete = true + + @column({ isPrimary: true }) + declare id: number + + @column() + declare title: string + + @column() + declare description: string | null + + @column({ columnName: 'is_completed' }) + declare isCompleted: boolean + + @column({ columnName: 'user_id' }) + declare userId: number + + @belongsTo(() => User) + declare user: BelongsTo + + @manyToMany(() => Label, { + pivotTable: 'label_todo', + pivotTimestamps: true, + }) + declare labels: ManyToMany + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime | null + + @column.dateTime() + declare deletedAt: DateTime | null +} \ No newline at end of file diff --git a/database/app.sqlite b/database/app.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..8ed8afdceb46dcc0191cef8e1a8d70869456e09e GIT binary patch literal 53248 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 { + table.increments('id').primary() + table.string('title').notNullable() + table.text('description').nullable() + table.boolean('is_completed').defaultTo(false) + table.integer('user_id').unsigned().references('id').inTable('users').onDelete('CASCADE') + + // Add soft delete column + table.timestamp('deleted_at').nullable() + + table.timestamp('created_at', { useTz: true }).notNullable() + table.timestamp('updated_at', { useTz: true }).notNullable() + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} \ No newline at end of file diff --git a/database/migrations/1752688099236_create_labels_table.ts b/database/migrations/1752688099236_create_labels_table.ts new file mode 100644 index 0000000..25b2786 --- /dev/null +++ b/database/migrations/1752688099236_create_labels_table.ts @@ -0,0 +1,20 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class Labels extends BaseSchema { + protected tableName = 'labels' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id').primary() + table.string('name').notNullable() + table.string('color').nullable() // Stores hex color (e.g., "#FF5733") + table.integer('user_id').unsigned().references('id').inTable('users').onDelete('CASCADE') + table.timestamp('created_at', { useTz: true }).notNullable() + table.timestamp('updated_at', { useTz: true }).notNullable() + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} \ No newline at end of file diff --git a/database/migrations/1752691389977_create_label_todos_table.ts b/database/migrations/1752691389977_create_label_todos_table.ts new file mode 100644 index 0000000..f6ecb09 --- /dev/null +++ b/database/migrations/1752691389977_create_label_todos_table.ts @@ -0,0 +1,21 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class LabelTodo extends BaseSchema { + protected tableName = 'label_todo' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id') + table.integer('todo_id').unsigned().references('id').inTable('todos').onDelete('CASCADE') + table.integer('label_id').unsigned().references('id').inTable('labels').onDelete('CASCADE') + table.timestamps(true) + + // Ensure unique pairs + table.unique(['todo_id', 'label_id']) + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} \ No newline at end of file diff --git a/database/seeders/label_seeder.ts b/database/seeders/label_seeder.ts new file mode 100644 index 0000000..6d800f9 --- /dev/null +++ b/database/seeders/label_seeder.ts @@ -0,0 +1,14 @@ +import Label from '#models/label' +import { BaseSeeder } from '@adonisjs/lucid/seeders' + +export default class LabelSeeder extends BaseSeeder { + async run() { + await Label.createMany([ + { name: 'Work', color: '#FF5733' }, + { name: 'Personal', color: '#33FF57' }, + { name: 'Urgent', color: '#FF3333' }, + { name: 'Shopping', color: '#3388FF' }, + { name: 'Ideas', color: '#F033FF' } + ]) + } +} \ No newline at end of file diff --git a/database/seeders/todo_seeder.ts b/database/seeders/todo_seeder.ts new file mode 100644 index 0000000..38bfdd0 --- /dev/null +++ b/database/seeders/todo_seeder.ts @@ -0,0 +1,29 @@ +import Todo from '#models/todo' +import Label from '#models/label' +import { BaseSeeder } from '@adonisjs/lucid/seeders' + +export default class TodoSeeder extends BaseSeeder { + async run() { + const labels = await Label.all() + const labelIds = labels.map(label => label.id) + + const todos = [ + { title: 'Complete project', description: 'Finish all tasks' }, + { title: 'Buy groceries', description: 'Milk, eggs, bread' }, + { title: 'Call mom', isCompleted: true }, + { title: 'Write blog post', description: 'About AdonisJS' }, + { title: 'Fix leak', isCompleted: false }, + { title: 'Plan trip', description: 'Book hotels' }, + { title: 'Learn recipe', isCompleted: true }, + { title: 'Organize desk', isCompleted: false }, + { title: 'Review PRs', description: 'Code reviews' }, + { title: 'Morning jog', isCompleted: true } + ] + + for (const todoData of todos) { + const todo = await Todo.create(todoData) + const randomLabels = labelIds.sort(() => 0.5 - Math.random()).slice(0, 2) + await todo.related('labels').attach(randomLabels) + } + } +} \ No newline at end of file diff --git a/start/routes.ts b/start/routes.ts index ca1f403..3d73156 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -7,13 +7,49 @@ | */ -const NotesController = () => import('#controllers/notes_controller') + +import ProjectsController from '#controllers/ProjectController' +import NotesController from '#controllers/NoteController' +import TodosController from '#controllers/TodoController' +import LabelsController from '#controllers/LabelController' + import router from '@adonisjs/core/services/router' + +// ======================== +// Inertia Routes (Frontend) +// ======================== router.get('/', ({ inertia }) => inertia.render('home')) -router.get('/todos', ({ inertia }) => inertia.render('todos/empty')) +// ======================== +// API Routes (Backend) +// ======================== +// Projects (keep existing) +router.get('/projects', [ProjectsController, 'index']) +router.get('/projects/create', [ProjectsController, 'create']) +router.post('/projects', [ProjectsController, 'store']) +router.get('/projects/:id', [ProjectsController, 'show']) +router.get('/projects/:id/edit', [ProjectsController, 'edit']) +router.put('/projects/:id', [ProjectsController, 'update']) +router.patch('/projects/:id/status', [ProjectsController, 'updateStatus']) +router.delete('/projects/:id', [ProjectsController, 'destroy']) + +// Notes (expanded) router.get('/notes', [NotesController, 'index']) router.post('/notes', [NotesController, 'store']) +router.get('/notes/:id', [NotesController, 'show']) router.put('/notes/:id', [NotesController, 'update']) router.delete('/notes/:id', [NotesController, 'destroy']) +router.patch('/notes/:id/pin', [NotesController, 'togglePin']) + +// Todos (new) +router.get('/todos/api', [TodosController, 'index']) // API endpoint for todos +router.post('/todos/api', [TodosController, 'store']) +router.get('/todos/api/:id', [TodosController, 'show']) +router.put('/todos/api/:id', [TodosController, 'update']) +router.delete('/todos/api/:id', [TodosController, 'destroy']) + +// Labels (new) +router.get('/labels', [LabelsController, 'index']) +router.post('/labels', [LabelsController, 'store']) +router.delete('/labels/:id', [LabelsController, 'destroy']) \ No newline at end of file From b0db455e00a4dbaa1d1373eb58c90de533130c73 Mon Sep 17 00:00:00 2001 From: abdullah-dev5 Date: Fri, 18 Jul 2025 00:45:16 +0500 Subject: [PATCH 2/3] feat: integrate Cloudinary and refactor modules with improved error handling (D7) - Added Cloudinary upload integration for media handling - Fixed label, todo, and notes module bugs in backend - Improved error handling across modules - Refactored code for better readability and structure --- app/controllers/LabelController.ts | 33 ++- app/controllers/NoteController.ts | 225 ++++++++++++++++++ app/controllers/TodoController.ts | 126 +++++++--- app/models/label.ts | 18 +- app/models/note.ts | 63 ++++- app/models/todo.ts | 4 +- .../labels/create_label_validator.ts | 9 + app/validators/notes/create_note_validator.ts | 17 ++ app/validators/notes/note_id_validator.ts | 8 + app/validators/notes/update_note_validator.ts | 17 ++ .../notes/upload_image_validator.ts | 13 + app/validators/todos/todos_validator.ts | 19 ++ config/cloudinary.ts | 24 ++ config/shield.ts | 2 +- database/app.sqlite | Bin 53248 -> 61440 bytes .../1741537012069_create_notes_table.ts | 20 -- ...752772099991_create_create_users_table.ts} | 12 +- ...2772104618_create_create_projects_table.ts | 29 +++ ...752772224351_create_create_todos_table.ts} | 11 +- ...52772229118_create_create_labels_table.ts} | 12 +- ...234396_create_create_label_todos_table.ts} | 12 +- ...1752772239922_create_create_notes_table.ts | 25 ++ ...9_create_create_label_note_pivots_table.ts | 36 +++ database/seeders/label_note_seeder.ts | 41 ++++ database/seeders/label_seeder.ts | 11 +- database/seeders/note_seeder.ts | 91 +++++++ database/seeders/user_seeder.ts | 48 ++++ package-lock.json | 45 ++++ package.json | 3 + start/env.ts | 9 + start/routes.ts | 116 +++++---- tests/GeneratedTest.png | Bin 0 -> 564 bytes tests/NewTest.png | Bin 0 -> 1231 bytes tests/Test.png | Bin 0 -> 87392 bytes tests/unit/note/stow.spec.ts | 4 +- tsconfig.json | 70 +++++- 36 files changed, 1009 insertions(+), 164 deletions(-) create mode 100644 app/controllers/NoteController.ts create mode 100644 app/validators/labels/create_label_validator.ts create mode 100644 app/validators/notes/create_note_validator.ts create mode 100644 app/validators/notes/note_id_validator.ts create mode 100644 app/validators/notes/update_note_validator.ts create mode 100644 app/validators/notes/upload_image_validator.ts create mode 100644 app/validators/todos/todos_validator.ts create mode 100644 config/cloudinary.ts delete mode 100644 database/migrations/1741537012069_create_notes_table.ts rename database/migrations/{1741531331077_create_users_table.ts => 1752772099991_create_create_users_table.ts} (57%) create mode 100644 database/migrations/1752772104618_create_create_projects_table.ts rename database/migrations/{1752686416737_create_todos_table.ts => 1752772224351_create_create_todos_table.ts} (72%) rename database/migrations/{1752688099236_create_labels_table.ts => 1752772229118_create_create_labels_table.ts} (55%) rename database/migrations/{1752691389977_create_label_todos_table.ts => 1752772234396_create_create_label_todos_table.ts} (74%) create mode 100644 database/migrations/1752772239922_create_create_notes_table.ts create mode 100644 database/migrations/1752774020689_create_create_label_note_pivots_table.ts create mode 100644 database/seeders/label_note_seeder.ts create mode 100644 database/seeders/note_seeder.ts create mode 100644 database/seeders/user_seeder.ts create mode 100644 tests/GeneratedTest.png create mode 100644 tests/NewTest.png create mode 100644 tests/Test.png diff --git a/app/controllers/LabelController.ts b/app/controllers/LabelController.ts index 786fcd1..b6d2cf9 100644 --- a/app/controllers/LabelController.ts +++ b/app/controllers/LabelController.ts @@ -1,22 +1,35 @@ +// start/controllers/labels_controller.ts import { HttpContext } from '@adonisjs/core/http' import Label from '#models/label' +import { createLabelValidator } from '#validators/labels/create_label_validator' export default class LabelsController { - // List all labels - public async index({ }: HttpContext) { - return await Label.all() + // GET /labels - List all labels for the authenticated user + public async index({ auth }: HttpContext) { + await auth.check() + const user = auth.user! + return await Label.query().where('userId', user.id) } - // Create a label - public async store({ request }: HttpContext) { - const data = request.only(['name', 'color']) - return await Label.create(data) + // POST /labels - Create a label + public async store({ request, auth }: HttpContext) { + await auth.check() + const user = auth.user! + const payload = await request.validateUsing(createLabelValidator) + return await Label.create({ ...payload, userId: user.id }) } - // Delete a label - public async destroy({ params, response }: HttpContext) { + // DELETE /labels/:id - Delete a label + public async destroy({ params, auth, response }: HttpContext) { + await auth.check() + const user = auth.user! + const label = await Label.findOrFail(params.id) + if (label.userId !== user.id) { + return response.unauthorized({ message: 'Not allowed to delete this label' }) + } + await label.delete() return response.noContent() } -} \ No newline at end of file +} diff --git a/app/controllers/NoteController.ts b/app/controllers/NoteController.ts new file mode 100644 index 0000000..3172bfc --- /dev/null +++ b/app/controllers/NoteController.ts @@ -0,0 +1,225 @@ +import { HttpContext } from '@adonisjs/core/http' +import Note from '#models/note' +import { marked } from 'marked' +import { randomUUID } from 'node:crypto' +import app from '@adonisjs/core/services/app' +import cloudinary from '#config/cloudinary' +import { DateTime } from 'luxon' + +import { createNoteValidator } from '#validators/notes/create_note_validator' +import { updateNoteValidator } from '#validators/notes/update_note_validator' +import { noteIdValidator } from '#validators/notes/note_id_validator' +import { uploadImageValidator } from '#validators/notes/upload_image_validator' + +export default class NotesController { + private isInertiaRequest(request: HttpContext['request']) { + return request.header('x-inertia') === 'true' + } + + async index({ request, inertia, response }: HttpContext) { + try { + const { sort = 'created_at', order = 'desc', search = '', page = 1, limit = 10, pinned, label_id } = request.qs() + + const query = Note.query() + .whereNull('deleted_at') + .preload('labels') + .orderBy('pinned', 'desc') + + if (search) { + query.where((q) => { + q.where('title', 'LIKE', `%${search}%`).orWhere('content', 'LIKE', `%${search}%`) + }) + } + + if (pinned !== undefined) { + query.where('pinned', pinned === 'true') + } + + if (label_id) { + query.whereHas('labels', (subQuery) => subQuery.where('id', label_id)) + } + + const notes = await query.orderBy(sort, order).paginate(Number(page), Number(limit)) + + if (this.isInertiaRequest(request)) { + return inertia.render('notes/index', { + notes: notes.serialize().data, + meta: notes.getMeta(), + sortOptions: { currentSort: sort, currentOrder: order, searchQuery: search }, + }) + } + + return notes + } catch (error) { + return response.status(500).send({ message: 'Failed to fetch notes', error: error.message }) + } + } + + async show({ params, request, response, inertia }: HttpContext) { + try { + const { note_id } = await request.validateUsing(noteIdValidator, { data: params }) + const note = await Note.query() + .where('id', note_id) + .whereNull('deleted_at') + .preload('labels') + .firstOrFail() + + return this.isInertiaRequest(request) + ? inertia.render('notes/show', { note: note.serialize() }) + : note + } catch (error) { + return response.status(404).send({ message: 'Note not found', error: error.message }) + } + } + + async store({ request, response }: HttpContext) { + try { + const payload = await request.validateUsing(createNoteValidator) + + const note = await Note.create({ + ...payload, + content: payload.content ? await marked.parse(payload.content) : undefined, + }) + + if (payload.labelIds?.length) { + await note.related('labels').attach(payload.labelIds) + } + + return this.isInertiaRequest(request) + ? response.redirect().back() + : response.created({ message: 'Note created successfully', note }) + } catch (error) { + return response.status(400).send({ message: 'Note creation failed', error: error.message }) + } + } + + async update({ request, response, params }: HttpContext) { + try { + const { note_id } = await request.validateUsing(noteIdValidator, { data: params }) + const payload = await request.validateUsing(updateNoteValidator) + + const note = await Note.findOrFail(note_id) + + note.merge({ + ...payload, + content: payload.content ? await marked.parse(payload.content) : note.content, + }) + + await note.save() + + if (payload.labelIds) { + await note.related('labels').sync(payload.labelIds) + } + + return this.isInertiaRequest(request) + ? response.redirect().back() + : response.ok({ message: 'Note updated successfully', note }) + } catch (error) { + return response.status(400).send({ message: 'Failed to update note', error: error.message }) + } + } + + async destroy({ request, params, response }: HttpContext) { + try { + const { note_id } = await request.validateUsing(noteIdValidator, { data: params }) + const note = await Note.findOrFail(note_id) + + note.deletedAt = DateTime.now() + await note.save() + + return this.isInertiaRequest(request) + ? response.redirect().toRoute('notes.index') + : response.ok({ message: 'Note moved to trash' }) + } catch (error) { + return response.status(400).send({ message: 'Failed to delete note', error: error.message }) + } + } + + async restore({ request, params, response }: HttpContext) { + try { + const { note_id } = await request.validateUsing(noteIdValidator, { data: params }) + const note = await Note.findOrFail(note_id) + + note.deletedAt = null + await note.save() + + return response.ok({ message: 'Note restored successfully', note }) + } catch (error) { + return response.status(400).send({ message: 'Restore failed', error: error.message }) + } + } + + async togglePin({ request, response, params }: HttpContext) { + try { + const { note_id } = await request.validateUsing(noteIdValidator, { data: params }) + const note = await Note.findOrFail(note_id) + + note.pinned = !note.pinned + await note.save() + + return this.isInertiaRequest(request) + ? response.redirect().back() + : response.ok({ message: 'Pin status updated', note }) + } catch (error) { + return response.status(400).send({ message: 'Failed to toggle pin', error: error.message }) + } + } + + async uploadImage({ request, response }: HttpContext) { + const { image } = await request.validateUsing(uploadImageValidator) + + if (!image) { + return response.status(400).send({ message: 'No image provided' }) + } + + try { + const fileName = `${randomUUID()}_${image.clientName}` + await image.move(app.tmpPath('uploads'), { name: fileName }) + + const result = await cloudinary.uploader.upload(app.tmpPath('uploads', fileName), { + folder: 'notes', + public_id: `note_${Date.now()}`, + resource_type: 'auto', + timeout: 10000, + }) + + return response.ok({ + message: 'Image uploaded successfully', + url: result.secure_url, + public_id: result.public_id, + asset_id: result.asset_id, + bytes: result.bytes, + }) + } catch (error) { + return response.status(500).send({ message: 'Image upload failed', error: error.message }) + } + } + + async generateShareLink({ params, request, response }: HttpContext) { + try { + const { note_id } = await request.validateUsing(noteIdValidator, { data: params }) + const note = await Note.findOrFail(note_id) + + note.shareUuid = randomUUID() + await note.save() + + return response.ok({ message: 'Share link generated', url: `/notes/shared/${note.shareUuid}` }) + } catch (error) { + return response.status(400).send({ message: 'Failed to generate share link', error: error.message }) + } + } + + async viewSharedNote({ params, response }: HttpContext) { + try { + const note = await Note.query() + .where('share_uuid', params.uuid) + .whereNull('deleted_at') + .preload('labels') + .firstOrFail() + + return response.ok(note) + } catch (error) { + return response.status(404).send({ message: 'Shared note not found', error: error.message }) + } + } +} diff --git a/app/controllers/TodoController.ts b/app/controllers/TodoController.ts index aabf11f..8549d89 100644 --- a/app/controllers/TodoController.ts +++ b/app/controllers/TodoController.ts @@ -1,58 +1,108 @@ import { HttpContext } from '@adonisjs/core/http' import Todo from '#models/todo' import { DateTime } from 'luxon' +import { createTodoValidator, updateTodoValidator } from '#validators/todos/todos_validator' export default class TodosController { - // Get all todos - public async index({ }: HttpContext) { - return await Todo.query() - .whereNull('deleted_at') - .preload('labels') + // GET /todos + public async index({ response }: HttpContext) { + try { + const todos = await Todo.query() + .whereNull('deleted_at') + .preload('labels') + + return response.ok(todos) + } catch (error) { + return response.internalServerError({ message: 'Failed to fetch todos', error: error.message }) + } } - // Get single todo + // GET /todos/:id public async show({ params, response }: HttpContext) { - const todo = await Todo.query() - .where('id', params.id) - .whereNull('deleted_at') - .preload('labels') - .first() - - if (!todo) { - return response.notFound({ message: 'Todo not found' }) - } + try { + const todo = await Todo.query() + .where('id', params.id) + .whereNull('deleted_at') + .preload('labels') + .first() - return todo + if (!todo) { + return response.notFound({ message: 'Todo not found' }) + } + + return response.ok(todo) + } catch (error) { + return response.internalServerError({ message: 'Failed to fetch todo', error: error.message }) + } } - // Create a todo - public async store({ request }: HttpContext) { - const { labelIds, ...data } = request.only(['title', 'description', 'isCompleted', 'labelIds']) - const todo = await Todo.create(data) + // POST /todos + public async store({ request, response }: HttpContext) { + try { + const payload = await request.validateUsing(createTodoValidator) - if (labelIds) await todo.related('labels').attach(labelIds) - await todo.load('labels') - return todo + const { labelIds = [], ...todoData } = payload + + const todo = await Todo.create(todoData) + + if (labelIds.length > 0) { + await todo.related('labels').attach(labelIds) + } + + await todo.load('labels') + return response.created(todo) + } catch (error) { + if ('messages' in error) { + return response.unprocessableEntity({ errors: error.messages }) + } + return response.internalServerError({ message: 'Failed to create todo', error: error.message }) + } } - // Update a todo - public async update({ params, request }: HttpContext) { - const todo = await Todo.findOrFail(params.id) - const { labelIds, ...data } = request.only(['title', 'description', 'isCompleted', 'labelIds']) + // PUT /todos/:id + public async update({ params, request, response }: HttpContext) { + try { + const todo = await Todo.find(params.id) - todo.merge(data) - await todo.save() + if (!todo || todo.deletedAt) { + return response.notFound({ message: 'Todo not found or deleted' }) + } - if (labelIds) await todo.related('labels').sync(labelIds) - await todo.load('labels') - return todo + const payload = await request.validateUsing(updateTodoValidator) + const { labelIds = [], ...updateData } = payload + + todo.merge(updateData) + await todo.save() + + if (labelIds.length > 0) { + await todo.related('labels').sync(labelIds) + } + + await todo.load('labels') + return response.ok(todo) + } catch (error) { + if ('messages' in error) { + return response.unprocessableEntity({ errors: error.messages }) + } + return response.internalServerError({ message: 'Failed to update todo', error: error.message }) + } } - // Soft delete + // DELETE /todos/:id public async destroy({ params, response }: HttpContext) { - const todo = await Todo.findOrFail(params.id) - todo.deletedAt = DateTime.now() - await todo.save() - return response.noContent() + try { + const todo = await Todo.find(params.id) + + if (!todo || todo.deletedAt) { + return response.notFound({ message: 'Todo not found or already deleted' }) + } + + todo.deletedAt = DateTime.now() + await todo.save() + + return response.noContent() + } catch (error) { + return response.internalServerError({ message: 'Failed to delete todo', error: error.message }) + } } -} \ No newline at end of file +} diff --git a/app/models/label.ts b/app/models/label.ts index f00a2b5..bad4797 100644 --- a/app/models/label.ts +++ b/app/models/label.ts @@ -1,9 +1,10 @@ +// start/models/label.ts import { BaseModel, column, belongsTo, manyToMany } from '@adonisjs/lucid/orm' import type { BelongsTo, ManyToMany } from '@adonisjs/lucid/types/relations' +import { DateTime } from 'luxon' +import User from '#models/user' import Todo from '#models/todo' import Note from '#models/note' -import User from '#models/user' -import { DateTime } from 'luxon' export default class Label extends BaseModel { @column({ isPrimary: true }) @@ -18,27 +19,24 @@ export default class Label extends BaseModel { @column({ columnName: 'user_id' }) declare userId: number - // Relationship: Label belongs to a User @belongsTo(() => User) - declare user: BelongsTo // Fixed import + declare user: BelongsTo - // Relationship: Label can be assigned to many Todos @manyToMany(() => Todo, { pivotTable: 'label_todo', pivotTimestamps: true, }) - declare todos: ManyToMany // Fixed import + declare todos: ManyToMany - // Relationship: Label can be assigned to many Notes @manyToMany(() => Note, { pivotTable: 'label_note', pivotTimestamps: true, }) - declare notes: ManyToMany // Fixed import + declare notes: ManyToMany @column.dateTime({ autoCreate: true }) declare createdAt: DateTime @column.dateTime({ autoCreate: true, autoUpdate: true }) - declare updatedAt: DateTime | null -} \ No newline at end of file + declare updatedAt: DateTime +} diff --git a/app/models/note.ts b/app/models/note.ts index cd989a9..6538c8a 100644 --- a/app/models/note.ts +++ b/app/models/note.ts @@ -1,19 +1,78 @@ import { DateTime } from 'luxon' -import { BaseModel, column } from '@adonisjs/lucid/orm' +import { BaseModel, column, manyToMany, belongsTo, beforeDelete } from '@adonisjs/lucid/orm' +import type { ManyToMany, BelongsTo } from '@adonisjs/lucid/types/relations' +import Label from './label.js' +import User from './user.js' +import cloudinary from '#config/cloudinary' +import { Exception } from '@adonisjs/core/exceptions' export default class Note extends BaseModel { @column({ isPrimary: true }) declare id: number + @column() + declare userId: number + @column() declare title: string @column() declare content: string + @column() + declare pinned: boolean + + @column() + declare imageUrl: string | null + + @column() + declare imagePublicId: string | null + + @column() + declare shareUuid: string | null + + @column.dateTime() + declare deletedAt: DateTime | null // ✅ Soft delete support + + @belongsTo(() => User) + declare user: BelongsTo + + @manyToMany(() => Label, { + pivotTable: 'label_note', + pivotTimestamps: true, + }) + declare labels: ManyToMany + @column.dateTime({ autoCreate: true }) declare createdAt: DateTime @column.dateTime({ autoCreate: true, autoUpdate: true }) declare updatedAt: DateTime | null -} \ No newline at end of file + + @beforeDelete() + static async cleanupCloudinaryAssets(note: Note) { + if (note.imagePublicId) { + try { + await cloudinary.uploader.destroy(note.imagePublicId) + } catch (error) { + throw new Exception( + `Failed to cleanup Cloudinary assets: ${error.message}`, + { status: 500 } + ) + } + } + } + + serialize() { + return { + ...super.serialize(), + labels: this.labels?.map((label) => label.serialize()) || [], + isShared: !!this.shareUuid, + hasImage: !!this.imageUrl, + } + } + + generateShareUrl(baseUrl: string): string | null { + return this.shareUuid ? `${baseUrl}/notes/shared/${this.shareUuid}` : null + } +} diff --git a/app/models/todo.ts b/app/models/todo.ts index 7019582..3ac6fca 100644 --- a/app/models/todo.ts +++ b/app/models/todo.ts @@ -28,7 +28,7 @@ export default class Todo extends BaseModel { @manyToMany(() => Label, { pivotTable: 'label_todo', - pivotTimestamps: true, + pivotTimestamps: true, // ✅ captures created_at/updated_at in pivot }) declare labels: ManyToMany @@ -40,4 +40,4 @@ export default class Todo extends BaseModel { @column.dateTime() declare deletedAt: DateTime | null -} \ No newline at end of file +} diff --git a/app/validators/labels/create_label_validator.ts b/app/validators/labels/create_label_validator.ts new file mode 100644 index 0000000..94fb4ca --- /dev/null +++ b/app/validators/labels/create_label_validator.ts @@ -0,0 +1,9 @@ +// start/validators/create_label_validator.ts +import vine from '@vinejs/vine' + +export const createLabelValidator = vine.compile( + vine.object({ + name: vine.string().trim().minLength(1).maxLength(255), + color: vine.string().optional().nullable(), + }) +) diff --git a/app/validators/notes/create_note_validator.ts b/app/validators/notes/create_note_validator.ts new file mode 100644 index 0000000..2af31d3 --- /dev/null +++ b/app/validators/notes/create_note_validator.ts @@ -0,0 +1,17 @@ +// app/validators/note/create_note_validator.ts +import vine from '@vinejs/vine' + +export const createNoteValidator = vine.compile( + vine.object({ + title: vine.string().minLength(3).maxLength(255), + content: vine.string().optional(), + pinned: vine.boolean().optional(), + image: vine + .file({ + size: '2mb', + extnames: ['jpg', 'jpeg', 'png', 'webp'], + }) + .optional(), + labelIds: vine.array(vine.number()).optional(), + }) +) diff --git a/app/validators/notes/note_id_validator.ts b/app/validators/notes/note_id_validator.ts new file mode 100644 index 0000000..df2f74c --- /dev/null +++ b/app/validators/notes/note_id_validator.ts @@ -0,0 +1,8 @@ +// app/validators/note/note_id_validator.ts +import vine from '@vinejs/vine' + +export const noteIdValidator = vine.compile( + vine.object({ + note_id: vine.number().positive(), + }) +) diff --git a/app/validators/notes/update_note_validator.ts b/app/validators/notes/update_note_validator.ts new file mode 100644 index 0000000..4b2ca3c --- /dev/null +++ b/app/validators/notes/update_note_validator.ts @@ -0,0 +1,17 @@ +// app/validators/note/update_note_validator.ts +import vine from '@vinejs/vine' + +export const updateNoteValidator = vine.compile( + vine.object({ + title: vine.string().minLength(3).maxLength(255).optional(), + content: vine.string().optional(), + pinned: vine.boolean().optional(), + image: vine + .file({ + size: '2mb', + extnames: ['jpg', 'jpeg', 'png', 'webp'], + }) + .optional(), + labelIds: vine.array(vine.number()).optional(), + }) +) diff --git a/app/validators/notes/upload_image_validator.ts b/app/validators/notes/upload_image_validator.ts new file mode 100644 index 0000000..f9a712f --- /dev/null +++ b/app/validators/notes/upload_image_validator.ts @@ -0,0 +1,13 @@ +// app/validators/note/upload_image_validator.ts +import vine from '@vinejs/vine' + +export const uploadImageValidator = vine.compile( + vine.object({ + image: vine + .file({ + size: '2mb', + extnames: ['jpg', 'png', 'jpeg', 'webp'], + }) + .optional(), // Optional if image is not always required + }) +) diff --git a/app/validators/todos/todos_validator.ts b/app/validators/todos/todos_validator.ts new file mode 100644 index 0000000..57a4bac --- /dev/null +++ b/app/validators/todos/todos_validator.ts @@ -0,0 +1,19 @@ +import vine from '@vinejs/vine' + +export const createTodoValidator = vine.compile( + vine.object({ + title: vine.string().trim().minLength(3).maxLength(255), + description: vine.string().nullable().optional(), + isCompleted: vine.boolean().optional(), + labelIds: vine.array(vine.number().positive()).optional(), + }) +) + +export const updateTodoValidator = vine.compile( + vine.object({ + title: vine.string().trim().minLength(3).maxLength(255).optional(), + description: vine.string().nullable().optional(), + isCompleted: vine.boolean().optional(), + labelIds: vine.array(vine.number().positive()).optional(), + }) +) diff --git a/config/cloudinary.ts b/config/cloudinary.ts new file mode 100644 index 0000000..2ab2ed1 --- /dev/null +++ b/config/cloudinary.ts @@ -0,0 +1,24 @@ +import env from '#start/env' +import { v2 as cloudinary } from 'cloudinary' +import { Exception } from '@adonisjs/core/exceptions' + +// Validate config on startup +const cloudinaryConfig = { + cloud_name: env.get('CLOUDINARY_CLOUD_NAME'), + api_key: env.get('CLOUDINARY_API_KEY'), + api_secret: env.get('CLOUDINARY_API_SECRET'), + secure: true +} + +// Verify required credentials +if (!cloudinaryConfig.cloud_name || !cloudinaryConfig.api_key || !cloudinaryConfig.api_secret) { + throw new Exception( + 'Missing Cloudinary configuration. Check your .env file', + { status: 500 } + ) +} + +// Initialize and export configured instance +cloudinary.config(cloudinaryConfig) + +export default cloudinary \ No newline at end of file diff --git a/config/shield.ts b/config/shield.ts index d3aa290..7ababc0 100644 --- a/config/shield.ts +++ b/config/shield.ts @@ -17,7 +17,7 @@ const shieldConfig = defineConfig({ */ csrf: { enabled: true, - exceptRoutes: [], + exceptRoutes: ['/notes/:id/upload'], enableXsrfCookie: true, methods: ['POST', 'PUT', 'PATCH', 'DELETE'], }, diff --git a/database/app.sqlite b/database/app.sqlite index 8ed8afdceb46dcc0191cef8e1a8d70869456e09e..b22173e71fe77592528ff05d3e1f296434febe8f 100644 GIT binary patch literal 61440 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 { - table.increments('id').notNullable() - table.string('title').notNullable() - table.text('content').notNullable() - - table.timestamp('created_at').notNullable() - table.timestamp('updated_at').nullable() - }) - } - - async down() { - this.schema.dropTable(this.tableName) - } -} \ No newline at end of file diff --git a/database/migrations/1741531331077_create_users_table.ts b/database/migrations/1752772099991_create_create_users_table.ts similarity index 57% rename from database/migrations/1741531331077_create_users_table.ts rename to database/migrations/1752772099991_create_create_users_table.ts index dbca083..be13cf4 100644 --- a/database/migrations/1741531331077_create_users_table.ts +++ b/database/migrations/1752772099991_create_create_users_table.ts @@ -1,3 +1,5 @@ +// database/migrations/xxxx_create_users_table.ts + import { BaseSchema } from '@adonisjs/lucid/schema' export default class extends BaseSchema { @@ -5,13 +7,11 @@ export default class extends BaseSchema { async up() { this.schema.createTable(this.tableName, (table) => { - table.increments('id').notNullable() - table.string('full_name').nullable() - table.string('email', 254).notNullable().unique() + table.increments('id') + table.string('full_name').notNullable().unique() + table.string('email').notNullable().unique() table.string('password').notNullable() - - table.timestamp('created_at').notNullable() - table.timestamp('updated_at').nullable() + table.timestamps(true) }) } diff --git a/database/migrations/1752772104618_create_create_projects_table.ts b/database/migrations/1752772104618_create_create_projects_table.ts new file mode 100644 index 0000000..ada9225 --- /dev/null +++ b/database/migrations/1752772104618_create_create_projects_table.ts @@ -0,0 +1,29 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'projects' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id') + table.string('title').notNullable() // updated from `name` to `title` + table.text('description').nullable() // added description + table + .enu('status', ['pending', 'in_progress', 'completed']) + .notNullable() + .defaultTo('pending') // added status with enum + table + .integer('user_id') + .unsigned() + .references('id') + .inTable('users') + .onDelete('CASCADE') + table.timestamp('created_at', { useTz: true }).defaultTo(this.now()) + table.timestamp('updated_at', { useTz: true }).defaultTo(this.now()) + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/database/migrations/1752686416737_create_todos_table.ts b/database/migrations/1752772224351_create_create_todos_table.ts similarity index 72% rename from database/migrations/1752686416737_create_todos_table.ts rename to database/migrations/1752772224351_create_create_todos_table.ts index d34add7..bce437c 100644 --- a/database/migrations/1752686416737_create_todos_table.ts +++ b/database/migrations/1752772224351_create_create_todos_table.ts @@ -5,21 +5,18 @@ export default class extends BaseSchema { async up() { this.schema.createTable(this.tableName, (table) => { - table.increments('id').primary() + table.increments('id') table.string('title').notNullable() table.text('description').nullable() table.boolean('is_completed').defaultTo(false) table.integer('user_id').unsigned().references('id').inTable('users').onDelete('CASCADE') - - // Add soft delete column + table.timestamp('created_at') + table.timestamp('updated_at') table.timestamp('deleted_at').nullable() - - table.timestamp('created_at', { useTz: true }).notNullable() - table.timestamp('updated_at', { useTz: true }).notNullable() }) } async down() { this.schema.dropTable(this.tableName) } -} \ No newline at end of file +} diff --git a/database/migrations/1752688099236_create_labels_table.ts b/database/migrations/1752772229118_create_create_labels_table.ts similarity index 55% rename from database/migrations/1752688099236_create_labels_table.ts rename to database/migrations/1752772229118_create_create_labels_table.ts index 25b2786..c8f9feb 100644 --- a/database/migrations/1752688099236_create_labels_table.ts +++ b/database/migrations/1752772229118_create_create_labels_table.ts @@ -1,20 +1,20 @@ import { BaseSchema } from '@adonisjs/lucid/schema' -export default class Labels extends BaseSchema { +export default class extends BaseSchema { protected tableName = 'labels' async up() { this.schema.createTable(this.tableName, (table) => { - table.increments('id').primary() + table.increments('id') table.string('name').notNullable() - table.string('color').nullable() // Stores hex color (e.g., "#FF5733") + table.string('color').nullable() table.integer('user_id').unsigned().references('id').inTable('users').onDelete('CASCADE') - table.timestamp('created_at', { useTz: true }).notNullable() - table.timestamp('updated_at', { useTz: true }).notNullable() + table.timestamp('created_at') + table.timestamp('updated_at') }) } async down() { this.schema.dropTable(this.tableName) } -} \ No newline at end of file +} diff --git a/database/migrations/1752691389977_create_label_todos_table.ts b/database/migrations/1752772234396_create_create_label_todos_table.ts similarity index 74% rename from database/migrations/1752691389977_create_label_todos_table.ts rename to database/migrations/1752772234396_create_create_label_todos_table.ts index f6ecb09..f978e6d 100644 --- a/database/migrations/1752691389977_create_label_todos_table.ts +++ b/database/migrations/1752772234396_create_create_label_todos_table.ts @@ -1,21 +1,19 @@ import { BaseSchema } from '@adonisjs/lucid/schema' -export default class LabelTodo extends BaseSchema { +export default class extends BaseSchema { protected tableName = 'label_todo' async up() { this.schema.createTable(this.tableName, (table) => { table.increments('id') - table.integer('todo_id').unsigned().references('id').inTable('todos').onDelete('CASCADE') table.integer('label_id').unsigned().references('id').inTable('labels').onDelete('CASCADE') - table.timestamps(true) - - // Ensure unique pairs - table.unique(['todo_id', 'label_id']) + table.integer('todo_id').unsigned().references('id').inTable('todos').onDelete('CASCADE') + table.timestamp('created_at') + table.timestamp('updated_at') }) } async down() { this.schema.dropTable(this.tableName) } -} \ No newline at end of file +} diff --git a/database/migrations/1752772239922_create_create_notes_table.ts b/database/migrations/1752772239922_create_create_notes_table.ts new file mode 100644 index 0000000..1ca4e23 --- /dev/null +++ b/database/migrations/1752772239922_create_create_notes_table.ts @@ -0,0 +1,25 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'notes' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id') + table.integer('user_id').unsigned().references('id').inTable('users').onDelete('CASCADE') + table.string('title').notNullable() + table.text('content').notNullable() + table.boolean('pinned').defaultTo(false) + table.string('image_url').nullable() + table.string('image_public_id').nullable() + table.string('share_uuid').nullable().unique() + table.timestamp('created_at') + table.timestamp('updated_at') + table.timestamp('deleted_at').nullable() + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/database/migrations/1752774020689_create_create_label_note_pivots_table.ts b/database/migrations/1752774020689_create_create_label_note_pivots_table.ts new file mode 100644 index 0000000..ff24e3c --- /dev/null +++ b/database/migrations/1752774020689_create_create_label_note_pivots_table.ts @@ -0,0 +1,36 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class CreateLabelNotePivot extends BaseSchema { + protected tableName = 'label_note' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id') + + table + .integer('note_id') + .unsigned() + .notNullable() + .references('id') + .inTable('notes') + .onDelete('CASCADE') + + table + .integer('label_id') + .unsigned() + .notNullable() + .references('id') + .inTable('labels') + .onDelete('CASCADE') + + table.timestamp('created_at', { useTz: true }).defaultTo(this.now()) + table.timestamp('updated_at', { useTz: true }).defaultTo(this.now()) + + table.unique(['note_id', 'label_id']) + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/database/seeders/label_note_seeder.ts b/database/seeders/label_note_seeder.ts new file mode 100644 index 0000000..2f35197 --- /dev/null +++ b/database/seeders/label_note_seeder.ts @@ -0,0 +1,41 @@ +import { BaseSeeder } from '@adonisjs/lucid/seeders' +import db from '@adonisjs/lucid/services/db' + +export default class LabelNoteSeeder extends BaseSeeder { + public async run() { + await db.from('label_note').delete() + + // Fetch existing note and label IDs + const notes = await db.from('notes').select('id') + const labels = await db.from('labels').select('id') + + if (!notes.length || !labels.length) { + console.warn('⚠️ No notes or labels found. Skipping label_note seeding.') + return + } + + const pivotData = [] + + for (let i = 0; i < notes.length; i++) { + const noteId = notes[i].id + + // Randomly associate 1–2 labels to each note + const assignedLabels = labels + .sort(() => 0.5 - Math.random()) // shuffle + .slice(0, Math.floor(Math.random() * 2) + 1) + + for (const label of assignedLabels) { + pivotData.push({ + note_id: noteId, + label_id: label.id, + }) + } + } + + if (pivotData.length > 0) { + await db.table('label_note').multiInsert(pivotData) + } else { + console.warn('⚠️ No label-note relationships created. Skipping insert.') + } + } +} diff --git a/database/seeders/label_seeder.ts b/database/seeders/label_seeder.ts index 6d800f9..fcf033c 100644 --- a/database/seeders/label_seeder.ts +++ b/database/seeders/label_seeder.ts @@ -4,11 +4,12 @@ import { BaseSeeder } from '@adonisjs/lucid/seeders' export default class LabelSeeder extends BaseSeeder { async run() { await Label.createMany([ - { name: 'Work', color: '#FF5733' }, - { name: 'Personal', color: '#33FF57' }, - { name: 'Urgent', color: '#FF3333' }, - { name: 'Shopping', color: '#3388FF' }, - { name: 'Ideas', color: '#F033FF' } + { name: 'Work', color: '#FF5733', userId: 1 }, + { name: 'Personal', color: '#33FF57', userId: 1 }, + { name: 'Urgent', color: '#FF3333', userId: 2 }, + { name: 'Shopping', color: '#3388FF', userId: 3 }, + { name: 'Ideas', color: '#F033FF', userId: 5 } ]) + } } \ No newline at end of file diff --git a/database/seeders/note_seeder.ts b/database/seeders/note_seeder.ts new file mode 100644 index 0000000..e5364b8 --- /dev/null +++ b/database/seeders/note_seeder.ts @@ -0,0 +1,91 @@ +import { BaseSeeder } from '@adonisjs/lucid/seeders' +import Note from '#models/note' +import Label from '#models/label' +import { DateTime } from 'luxon' + +export default class NoteSeeder extends BaseSeeder { + public async run() { + // 1. Create Notes + await Note.createMany([ + { + title: 'First Note with Image', + content: 'This note includes a Cloudinary image.', + pinned: false, + imageUrl: 'https://res.cloudinary.com/dfk9chls7/image/upload/v1/NewTest.png', + imagePublicId: 'NewTest', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + }, + { + title: 'Pinned Note', + content: 'This is a pinned note for testing filters.', + pinned: true, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + }, + { + title: 'Archived Note', + content: 'This note is soft deleted (for testing).', + pinned: false, + deletedAt: DateTime.now(), + createdAt: DateTime.now().minus({ days: 2 }), + updatedAt: DateTime.now().minus({ days: 2 }), + }, + { + title: 'Note with Another Image', + content: 'Second test note with image.', + pinned: false, + imageUrl: 'https://res.cloudinary.com/dfk9chls7/image/upload/v1/Test.png', + imagePublicId: 'Test', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + }, + { + title: 'Todo Ideas', + content: 'Brainstorm tasks for next sprint.', + pinned: false, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + }, + { + title: 'Shopping List', + content: 'Milk, Eggs, Bread, Detergent.', + pinned: true, + createdAt: DateTime.now().minus({ hours: 4 }), + updatedAt: DateTime.now().minus({ hours: 4 }), + }, + { + title: 'Meeting Notes', + content: 'Discussed API rate limits and response times.', + pinned: false, + createdAt: DateTime.now().minus({ days: 1 }), + updatedAt: DateTime.now().minus({ days: 1 }), + }, + { + title: 'Daily Journal', + content: 'Today I learned about VineJS validation in Adonis.', + pinned: false, + createdAt: DateTime.now(), + + updatedAt: DateTime.now(), + }, + { + title: 'Book Recommendations', + content: 'Deep Work, Atomic Habits, Clean Code.', + pinned: true, + createdAt: DateTime.now().minus({ days: 3 }), + updatedAt: DateTime.now().minus({ days: 2 }), + }, + ]) + + // 2. Attach 1–2 random labels to each note + const notes = await Note.all() + const labels = await Label.all() + + for (const note of notes) { + const shuffled = [...labels].sort(() => 0.5 - Math.random()) + const labelIds = shuffled.slice(0, Math.floor(Math.random() * 2) + 1).map(label => label.id) + await note.related('labels').attach(labelIds) + } + } +} diff --git a/database/seeders/user_seeder.ts b/database/seeders/user_seeder.ts new file mode 100644 index 0000000..d28c945 --- /dev/null +++ b/database/seeders/user_seeder.ts @@ -0,0 +1,48 @@ +import { BaseSeeder } from '@adonisjs/lucid/seeders' +import User from '#models/user' +import hash from '@adonisjs/core/services/hash' + +export default class UserSeeder extends BaseSeeder { + public async run() { + const simplePassword = await hash.make('123456') + await User.truncate(true) // 👈 Clears users table and resets auto-increment ID + await User.createMany([ + { + id: 1, + fullName: 'Muhammad Abdullah', + email: 'abdullah@example.com', + password: simplePassword, + }, + { + id: 2, + fullName: 'John Doe', + email: 'john@example.com', + password: simplePassword, + }, + { + id: 3, + fullName: 'Jane Smith', + email: 'jane@example.com', + password: simplePassword, + }, + { + id: 4, + fullName: 'Ali Khan', + email: 'ali@example.com', + password: simplePassword, + }, + { + id: 5, + fullName: 'Sarah Lee', + email: 'sarah@example.com', + password: simplePassword, + }, + { + id: 6, + fullName: 'David Park', + email: 'david@example.com', + password: simplePassword, + } + ]) + } +} diff --git a/package-lock.json b/package-lock.json index 9735461..906c25e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,13 +19,16 @@ "@adonisjs/static": "^1.1.1", "@adonisjs/vite": "^4.0.0", "@inertiajs/react": "^2.0.5", + "@types/marked": "^6.0.0", "@vinejs/vine": "^3.0.0", "better-sqlite3": "^11.8.1", + "cloudinary": "^2.7.0", "date-fns": "^4.1.0", "edge.js": "^6.2.1", "framer-motion": "^12.4.10", "lucide-react": "^0.479.0", "luxon": "^3.5.0", + "marked": "^16.1.2", "react": "^19.0.0", "react-dom": "^19.0.0", "reflect-metadata": "^0.2.2" @@ -4305,6 +4308,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/marked": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/marked/-/marked-6.0.0.tgz", + "integrity": "sha512-jmjpa4BwUsmhxcfsgUit/7A9KbrC48Q0q8KvnY107ogcjGgTFDlIL3RpihNpx2Mu1hM4mdFQjoVc4O6JoGKHsA==", + "deprecated": "This is a stub types definition. marked provides its own type definitions, so you do not need this installed.", + "dependencies": { + "marked": "*" + } + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -5810,6 +5822,18 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/cloudinary": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.7.0.tgz", + "integrity": "sha512-qrqDn31+qkMCzKu1GfRpzPNAO86jchcNwEHCUiqvPHNSFqu7FTNF9FuAkBUyvM1CFFgFPu64NT0DyeREwLwK0w==", + "dependencies": { + "lodash": "^4.17.21", + "q": "^1.5.1" + }, + "engines": { + "node": ">=9" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -10194,6 +10218,17 @@ "tmpl": "1.0.5" } }, + "node_modules/marked": { + "version": "16.1.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.1.2.tgz", + "integrity": "sha512-rNQt5EvRinalby7zJZu/mB+BvaAY2oz3wCuCjt1RDrWNpS1Pdf9xqMOeC9Hm5adBdcV/3XZPJpG58eT+WBc0XQ==", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -11429,6 +11464,16 @@ ], "license": "MIT" }, + "node_modules/q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", + "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", + "engines": { + "node": ">=0.6.0", + "teleport": ">=0.2.0" + } + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", diff --git a/package.json b/package.json index 9751675..1cbe73f 100644 --- a/package.json +++ b/package.json @@ -78,13 +78,16 @@ "@adonisjs/static": "^1.1.1", "@adonisjs/vite": "^4.0.0", "@inertiajs/react": "^2.0.5", + "@types/marked": "^6.0.0", "@vinejs/vine": "^3.0.0", "better-sqlite3": "^11.8.1", + "cloudinary": "^2.7.0", "date-fns": "^4.1.0", "edge.js": "^6.2.1", "framer-motion": "^12.4.10", "lucide-react": "^0.479.0", "luxon": "^3.5.0", + "marked": "^16.1.2", "react": "^19.0.0", "react-dom": "^19.0.0", "reflect-metadata": "^0.2.2" diff --git a/start/env.ts b/start/env.ts index 39e4874..bd7f5f6 100644 --- a/start/env.ts +++ b/start/env.ts @@ -24,4 +24,13 @@ export default await Env.create(new URL('../', import.meta.url), { |---------------------------------------------------------- */ SESSION_DRIVER: Env.schema.enum(['cookie', 'memory'] as const), + + /* + |---------------------------------------------------------- + | Variables for configuring Cloudinary + |---------------------------------------------------------- + */ + CLOUDINARY_CLOUD_NAME: Env.schema.string.optional(), + CLOUDINARY_API_KEY: Env.schema.string.optional(), + CLOUDINARY_API_SECRET: Env.schema.string.optional(), }) diff --git a/start/routes.ts b/start/routes.ts index 3d73156..5c4ee40 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -1,55 +1,85 @@ -/* -|-------------------------------------------------------------------------- -| Routes file -|-------------------------------------------------------------------------- -| -| The routes file is used for defining the HTTP routes. -| -*/ +// start/routes.ts +import router from '@adonisjs/core/services/router' +import LabelsController from '#controllers/LabelController' -import ProjectsController from '#controllers/ProjectController' +// Controllers import NotesController from '#controllers/NoteController' -import TodosController from '#controllers/TodoController' -import LabelsController from '#controllers/LabelController' -import router from '@adonisjs/core/services/router' +import TodosController from '#controllers/TodoController' +// Middleware +const authMiddleware = () => import('#middleware/auth_middleware') // ======================== -// Inertia Routes (Frontend) +// Inertia Route (Frontend) // ======================== router.get('/', ({ inertia }) => inertia.render('home')) // ======================== -// API Routes (Backend) +// API Routes // ======================== -// Projects (keep existing) -router.get('/projects', [ProjectsController, 'index']) -router.get('/projects/create', [ProjectsController, 'create']) -router.post('/projects', [ProjectsController, 'store']) -router.get('/projects/:id', [ProjectsController, 'show']) -router.get('/projects/:id/edit', [ProjectsController, 'edit']) -router.put('/projects/:id', [ProjectsController, 'update']) -router.patch('/projects/:id/status', [ProjectsController, 'updateStatus']) -router.delete('/projects/:id', [ProjectsController, 'destroy']) - -// Notes (expanded) -router.get('/notes', [NotesController, 'index']) -router.post('/notes', [NotesController, 'store']) -router.get('/notes/:id', [NotesController, 'show']) -router.put('/notes/:id', [NotesController, 'update']) -router.delete('/notes/:id', [NotesController, 'destroy']) -router.patch('/notes/:id/pin', [NotesController, 'togglePin']) - -// Todos (new) -router.get('/todos/api', [TodosController, 'index']) // API endpoint for todos -router.post('/todos/api', [TodosController, 'store']) -router.get('/todos/api/:id', [TodosController, 'show']) -router.put('/todos/api/:id', [TodosController, 'update']) -router.delete('/todos/api/:id', [TodosController, 'destroy']) - -// Labels (new) -router.get('/labels', [LabelsController, 'index']) -router.post('/labels', [LabelsController, 'store']) -router.delete('/labels/:id', [LabelsController, 'destroy']) \ No newline at end of file + +// Public route to view shared note (TODO: implement viewSharedNote method) +router.get('/notes/shared/:uuid', [NotesController, 'viewSharedNote']) + +// Auth-protected notes routes +router + .group(() => { + router.get('/', [NotesController, 'index']) + router.post('/', [NotesController, 'store']) + router.post('/upload', [NotesController, 'uploadImage']) + + router.get('/:note_id', [NotesController, 'show']) + router.put('/:note_id', [NotesController, 'update']) + router.delete('/:note_id', [NotesController, 'destroy']) + router.patch('/:note_id/pin', [NotesController, 'togglePin']) + router.patch('/:note_id/restore', [NotesController, 'restore']) + router.post('/:note_id/share', [NotesController, 'generateShareLink']) + }) + .prefix('/notes') + .use(authMiddleware) // 🔐 protect all notes-related routes + +// ========== Modules Below Will Be Enabled Later ========== + +// 🔹 Projects +/* +import ProjectsController from '#controllers/ProjectController' +import { projectIdValidator } from '#validators/projects/project_id_validator' + +router.group(() => { + router.get('/', [ProjectsController, 'index']) + router.get('/create', [ProjectsController, 'create']) + router.post('/', [ProjectsController, 'store']) + + router.get('/:id', [ProjectsController, 'show']).use(projectIdValidator) + router.get('/:id/edit', [ProjectsController, 'edit']).use(projectIdValidator) + router.put('/:id', [ProjectsController, 'update']).use(projectIdValidator) + router.patch('/:id/status', [ProjectsController, 'updateStatus']).use(projectIdValidator) + router.delete('/:id', [ProjectsController, 'destroy']).use(projectIdValidator) +}).prefix('/projects') +*/ + +// 🔹 Todos + +// Enable Todos Routes +router + .group(() => { + router.get('/', [TodosController, 'index']) // GET /todos + router.post('/', [TodosController, 'store']) // POST /todos + router.get('/:id', [TodosController, 'show']) // GET /todos/:id + router.put('/:id', [TodosController, 'update']) // PUT /todos/:id + router.delete('/:id', [TodosController, 'destroy']) // DELETE /todos/:id + }) + .prefix('/todos') + .use(authMiddleware) + +// 🔹 Labels +router + .group(() => { + router.get('/', [LabelsController, 'index']) + router.post('/', [LabelsController, 'store']) + router.delete('/:id', [LabelsController, 'destroy']) + }) + .prefix('/labels') + .use(authMiddleware) diff --git a/tests/GeneratedTest.png b/tests/GeneratedTest.png new file mode 100644 index 0000000000000000000000000000000000000000..9bc3750a96625a10ae83c34ecfdd679813c36d38 GIT binary patch literal 564 zcmeAS@N?(olHy`uVBq!ia0vp^DImhW&y1R%kD@H(E}q?If;Ra5&V7M>t0T}mh$9^+zwz& OGkCiCxvX9oe literal 0 HcmV?d00001 diff --git a/tests/NewTest.png b/tests/NewTest.png new file mode 100644 index 0000000000000000000000000000000000000000..64303148a12687418fbf857f3f4213c2bb5d4fb1 GIT binary patch literal 1231 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ß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 BY+?Wa literal 0 HcmV?d00001 diff --git a/tests/unit/note/stow.spec.ts b/tests/unit/note/stow.spec.ts index 8ca10df..4a45230 100644 --- a/tests/unit/note/stow.spec.ts +++ b/tests/unit/note/stow.spec.ts @@ -1,8 +1,8 @@ import Note from '#models/note' import { test } from '@japa/runner' -import { afterEach, beforeEach } from 'node:test' +import { afterEach } from 'node:test' -test.group('Notes Show', (group) => { +test.group('Notes Show', () => { let createdNote: Note | null afterEach(async () => { if (createdNote) { diff --git a/tsconfig.json b/tsconfig.json index f132908..71c336b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,13 +3,73 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "#inertia/*": ["./inertia/*.js"] + "#controllers/*": [ + "./app/controllers/*.js" + ], + "#exceptions/*": [ + "./app/exceptions/*.js" + ], + "#models/*": [ + "./app/models/*.js" + ], + "#mails/*": [ + "./app/mails/*.js" + ], + "#services/*": [ + "./app/services/*.js" + ], + "#listeners/*": [ + "./app/listeners/*.js" + ], + "#events/*": [ + "./app/events/*.js" + ], + "#middleware/*": [ + "./app/middleware/*.js" + ], + "#validators/*": [ + "./app/validators/*.js" + ], + "#providers/*": [ + "./providers/*.js" + ], + "#policies/*": [ + "./app/policies/*.js" + ], + "#abilities/*": [ + "./app/abilities/*.js" + ], + "#database/*": [ + "./database/*.js" + ], + "#tests/*": [ + "./tests/*.js" + ], + "#start/*": [ + "./start/*.js" + ], + "#config/*": [ + "./config/*.js" + ], + "#inertia/*": [ + "./inertia/*.js" + ] }, "rootDir": "./", "outDir": "./build", "jsx": "react-jsx", - "types": ["jest", "node"], - "lib": ["dom", "esnext"] + "types": [ + "jest", + "node" + ], + "lib": [ + "dom", + "esnext" + ] }, - "exclude": ["./inertia/**/*", "node_modules", "build"] -} + "exclude": [ + "./inertia/**/*", + "node_modules", + "build" + ] +} \ No newline at end of file From 3707d2c009170d6ab42dfc2b0d7fbc656bd3b75c Mon Sep 17 00:00:00 2001 From: abdullah-dev5 Date: Fri, 18 Jul 2025 23:34:18 +0500 Subject: [PATCH 3/3] feat(notes): add image uploads and labels with Cloudinary integration - Implement secure image upload/delete via Cloudinary CDN - Add label management system with validation - Generate shareable links for notes - Include production error handling and format validation --- app/controllers/NoteController.ts | 228 ++++++++++++++++-- app/controllers/ProjectController.ts | 152 ++++++++++++ app/controllers/TodoController.ts | 3 +- app/models/project.ts | 26 ++ app/validators/notes/create_note_validator.ts | 16 +- app/validators/notes/update_note_validator.ts | 6 +- app/validators/projects/project.ts | 9 + app/validators/projects/project_status.ts | 7 + config/shield.ts | 4 +- database/app.sqlite | Bin 61440 -> 61440 bytes ...2772104618_create_create_projects_table.ts | 32 +-- package-lock.json | 102 +++++++- package.json | 7 +- start/routes.ts | 62 ++--- todos.txt | 2 + 15 files changed, 570 insertions(+), 86 deletions(-) create mode 100644 app/controllers/ProjectController.ts create mode 100644 app/models/project.ts create mode 100644 app/validators/projects/project.ts create mode 100644 app/validators/projects/project_status.ts create mode 100644 todos.txt diff --git a/app/controllers/NoteController.ts b/app/controllers/NoteController.ts index 3172bfc..5ff9175 100644 --- a/app/controllers/NoteController.ts +++ b/app/controllers/NoteController.ts @@ -5,11 +5,15 @@ import { randomUUID } from 'node:crypto' import app from '@adonisjs/core/services/app' import cloudinary from '#config/cloudinary' import { DateTime } from 'luxon' - +//import fs from 'fs-extra'// used for store method only in developement purpose +import logger from '@adonisjs/core/services/logger' import { createNoteValidator } from '#validators/notes/create_note_validator' import { updateNoteValidator } from '#validators/notes/update_note_validator' import { noteIdValidator } from '#validators/notes/note_id_validator' import { uploadImageValidator } from '#validators/notes/upload_image_validator' +import { cuid } from '@adonisjs/core/helpers' +import type { MultipartFile } from '@adonisjs/core/types/bodyparser' // Import MultipartFile type +import Label from '#models/label' // Import Label model export default class NotesController { private isInertiaRequest(request: HttpContext['request']) { @@ -72,53 +76,243 @@ export default class NotesController { } } + + + //developement purpose only later will be removed + // async store({ request, response }: HttpContext) { + // try { + // const payload = await request.validateUsing(createNoteValidator) + // console.log('Received payload:', payload) + + // // Debug: Log all files received + // console.log('All files:', request.allFiles()) + + // const noteData: Partial = { + // title: payload.title, + // content: payload.content ? await marked.parse(payload.content) : undefined, + // pinned: payload.pinned ?? false, + // } + + // // Handle image upload if present + // if (payload.image) { + // console.log('Processing image upload...') + + // // Ensure uploads directory exists + // await fs.ensureDir(app.tmpPath('uploads')) + + // const fileName = `${randomUUID()}_${payload.image.clientName}` + // const filePath = app.tmpPath('uploads', fileName) + + // // Debug: Log before file move + // console.log('Moving file to:', filePath) + + // await payload.image.move(app.tmpPath('uploads'), { + // name: fileName, + // overwrite: true + // }) + + // // Debug: Verify file exists after move + // console.log('File exists after move?', await fs.exists(filePath)) + + // const result = await cloudinary.uploader.upload(filePath, { + // folder: 'notes', + // public_id: `note_${Date.now()}`, + // resource_type: 'auto', + // }) + + // console.log('Cloudinary upload result:', result) + + // noteData.imageUrl = result.secure_url + // noteData.imagePublicId = result.public_id + // } + + // const note = await Note.create(noteData) + + // if (payload.labelIds?.length) { + // await note.related('labels').attach(payload.labelIds) + // } + + // return this.isInertiaRequest(request) + // ? response.redirect().back() + // : response.created({ message: 'Note created successfully', note }) + // } catch (error) { + // console.error('Full error:', error) + // return response.status(400).send({ + // message: 'Note creation failed', + // error: error.message, + // stack: error.stack // Only for development + // }) + // } + // } + + + //this is prod ready async store({ request, response }: HttpContext) { try { const payload = await request.validateUsing(createNoteValidator) - const note = await Note.create({ - ...payload, - content: payload.content ? await marked.parse(payload.content) : undefined, - }) + const noteData: Partial = { + title: payload.title, + content: payload.content ? await marked.parse(payload.content) : '', + pinned: payload.pinned ?? false, + imageUrl: null, + imagePublicId: null + } + // Handle image upload + if (payload.image) { + const uploadResult = await this.uploadToCloudinary(payload.image) + noteData.imageUrl = uploadResult.secure_url + noteData.imagePublicId = uploadResult.public_id + } + + const note = await Note.create(noteData) + + // Handle labels transactionally if (payload.labelIds?.length) { - await note.related('labels').attach(payload.labelIds) + await this.safeAttachLabels(note, payload.labelIds) } - return this.isInertiaRequest(request) - ? response.redirect().back() - : response.created({ message: 'Note created successfully', note }) + return response.created({ + message: 'Note created successfully', + note: await note.load('labels') + }) + } catch (error) { - return response.status(400).send({ message: 'Note creation failed', error: error.message }) + logger.error(error, 'Note creation failed') + return response.status(400).json({ + message: 'Note creation failed', + error: error.message + }) } } + private async uploadToCloudinary(image: MultipartFile) { + const fileName = `${cuid()}_${image.clientName}` + const uploadPath = app.tmpPath('uploads', fileName) + + await image.move(app.tmpPath('uploads'), { + name: fileName, + overwrite: false // Prevent overwrite attacks + }) + + return cloudinary.uploader.upload(uploadPath, { + folder: process.env.CLOUDINARY_FOLDER || 'notes', + public_id: `note_${Date.now()}`, + resource_type: 'auto', + allowed_formats: ['jpg', 'jpeg', 'png', 'webp'] // Explicit allowlist + }) + } + + private async safeAttachLabels(note: Note, labelIds: number[]) { + try { + // Verify labels exist first + const existingLabels = await Label.query() + .whereIn('id', labelIds) + .select('id') + + if (existingLabels.length !== labelIds.length) { + throw new Error('One or more labels do not exist') + } + + await note.related('labels').attach(labelIds) + } catch (error) { + logger.error('Label attachment failed', { noteId: note.id, labelIds }) + throw error // Re-throw for global handler + } + } + + + + + + + + + async update({ request, response, params }: HttpContext) { try { const { note_id } = await request.validateUsing(noteIdValidator, { data: params }) const payload = await request.validateUsing(updateNoteValidator) - const note = await Note.findOrFail(note_id) - note.merge({ - ...payload, + // Prepare update data object (unchanged) + const updateData: Partial = { + title: payload.title ?? note.title, content: payload.content ? await marked.parse(payload.content) : note.content, - }) + pinned: payload.pinned ?? note.pinned, + } + + // Handle image changes (unchanged) + if (payload.image) { + const fileName = `${randomUUID()}_${payload.image.clientName}` + await payload.image.move(app.tmpPath('uploads'), { name: fileName }) + + const result = await cloudinary.uploader.upload(app.tmpPath('uploads', fileName), { + folder: 'notes', + public_id: `note_${Date.now()}`, + resource_type: 'auto', + }) + if (note.imagePublicId) { + try { + await cloudinary.uploader.destroy(note.imagePublicId) + } catch (error) { + logger.error('Failed to delete old image:', error) + } + } + + updateData.imageUrl = result.secure_url + updateData.imagePublicId = result.public_id + } else if (payload.removeImage) { + if (note.imagePublicId) { + try { + await cloudinary.uploader.destroy(note.imagePublicId) + } catch (error) { + logger.error('Failed to delete image:', error) + } + } + updateData.imageUrl = null + updateData.imagePublicId = null + } + + // Update note with all changes + note.merge(updateData) await note.save() + // Handle labels if provided - now with proper TypeScript support if (payload.labelIds) { - await note.related('labels').sync(payload.labelIds) + // First detach all existing labels + await note.related('labels').detach() + + // Then attach new ones if any exist + if (payload.labelIds.length > 0) { + await note.related('labels').attach(payload.labelIds) + } } return this.isInertiaRequest(request) ? response.redirect().back() - : response.ok({ message: 'Note updated successfully', note }) + : response.ok({ + message: 'Note updated successfully', + note: await note.load('labels') + }) } catch (error) { - return response.status(400).send({ message: 'Failed to update note', error: error.message }) + logger.error(error) + return response.status(400).send({ + message: 'Failed to update note', + error: error.message + }) } } + + + + + + + async destroy({ request, params, response }: HttpContext) { try { const { note_id } = await request.validateUsing(noteIdValidator, { data: params }) diff --git a/app/controllers/ProjectController.ts b/app/controllers/ProjectController.ts new file mode 100644 index 0000000..98ce134 --- /dev/null +++ b/app/controllers/ProjectController.ts @@ -0,0 +1,152 @@ +import Project from '#models/project' +import { HttpContext } from '@adonisjs/core/http' +import { projectValidator } from '#validators/projects/project' +import { projectStatusValidator } from '#validators/projects/project_status' + +export default class ProjectsController { + /** + * List all projects with pagination + */ + async index({ inertia, request /*, auth */ }: HttpContext) { + const page = request.input('page', 1) + const status = request.input('status') + + // const user = auth.user + + const projects = await Project.query() + // .where('user_id', user.id) // ← Uncomment when auth integrated + .orderBy('createdAt', 'desc') + .if(status, (query) => query.where('status', status)) + .paginate(page, 10) + + return inertia.render('projects/index', { + projects: { + data: projects.toJSON().data, + meta: projects.toJSON().meta, + }, + }) + } + + /** + * Show project creation form + */ + async create({ inertia /*, auth */ }: HttpContext) { + // const user = auth.user + const statusOptions = ['pending', 'in_progress', 'completed'] + return inertia.render('projects/create', { statusOptions }) + } + + /** + * Store new project + */ + async store({ request, response /*, auth */ }: HttpContext) { + try { + const payload = await request.validateUsing(projectValidator) + + // payload.userId = auth.user?.id! + + await Project.create(payload) + return response.redirect('/projects') + } catch (error) { + if ('messages' in error) { + return response.badRequest({ errors: error.messages }) + } + return response.internalServerError({ message: 'Failed to create project', error: error.message }) + } + } + + /** + * Show single project + */ + async show({ params, inertia /*, auth */ }: HttpContext) { + const project = await Project.findOrFail(params.id) + + // if (project.userId !== auth.user?.id) { + // return response.unauthorized('Unauthorized access') + // } + + return inertia.render('projects/show', { project }) + } + + /** + * Show project edit form + */ + async edit({ params, inertia /*, auth */ }: HttpContext) { + const project = await Project.findOrFail(params.id) + + // if (project.userId !== auth.user?.id) { + // return response.unauthorized('Unauthorized access') + // } + + const statusOptions = ['pending', 'in_progress', 'completed'] + return inertia.render('projects/edit', { project, statusOptions }) + } + + /** + * Update project + */ + async update({ params, request, response /*, auth */ }: HttpContext) { + try { + const project = await Project.findOrFail(params.id) + + // if (project.userId !== auth.user?.id) { + // return response.unauthorized('Unauthorized update') + // } + + const payload = await request.validateUsing(projectValidator) + + project.merge(payload) + await project.save() + + return response.redirect('/projects') + } catch (error) { + if ('messages' in error) { + return response.badRequest({ errors: error.messages }) + } + return response.internalServerError({ message: 'Failed to update project', error: error.message }) + } + } + + /** + * Delete project + */ + async destroy({ params, response /*, auth */ }: HttpContext) { + try { + const project = await Project.findOrFail(params.id) + + // if (project.userId !== auth.user?.id) { + // return response.unauthorized('Unauthorized delete') + // } + + await project.delete() + return response.redirect().toRoute('projects.index') + } catch (error) { + return response.internalServerError({ message: 'Failed to delete project', error: error.message }) + } + } + + /** + * Update project status + */ + async updateStatus({ params, request, response /*, auth */ }: HttpContext) { + try { + const project = await Project.findOrFail(params.id) + + // if (project.userId !== auth.user?.id) { + // return response.unauthorized('Unauthorized status change') + // } + + const { status } = await request.validateUsing(projectStatusValidator) + + project.status = status + await project.save() + + return response.redirect().back() + } catch (error) { + if ('messages' in error) { + return response.badRequest({ errors: error.messages }) + } + return response.internalServerError({ message: 'Failed to update status', error: error.message }) + } + } +} diff --git a/app/controllers/TodoController.ts b/app/controllers/TodoController.ts index 8549d89..3204ccb 100644 --- a/app/controllers/TodoController.ts +++ b/app/controllers/TodoController.ts @@ -100,7 +100,8 @@ export default class TodosController { todo.deletedAt = DateTime.now() await todo.save() - return response.noContent() + return response.ok({ message: 'Todo soft-deleted successfully' }) + } catch (error) { return response.internalServerError({ message: 'Failed to delete todo', error: error.message }) } diff --git a/app/models/project.ts b/app/models/project.ts new file mode 100644 index 0000000..c6f7ad0 --- /dev/null +++ b/app/models/project.ts @@ -0,0 +1,26 @@ +import { BaseModel, column } from '@adonisjs/lucid/orm' +import { DateTime } from 'luxon' + +export default class Project extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare title: string + + @column() + declare description: string + + @column() + declare status: 'pending' | 'in_progress' | 'completed' + + // Uncomment when auth is integrated + // @column() + // declare userId: number + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime +} diff --git a/app/validators/notes/create_note_validator.ts b/app/validators/notes/create_note_validator.ts index 2af31d3..fcb06a7 100644 --- a/app/validators/notes/create_note_validator.ts +++ b/app/validators/notes/create_note_validator.ts @@ -3,15 +3,13 @@ import vine from '@vinejs/vine' export const createNoteValidator = vine.compile( vine.object({ - title: vine.string().minLength(3).maxLength(255), + title: vine.string().minLength(3), content: vine.string().optional(), pinned: vine.boolean().optional(), - image: vine - .file({ - size: '2mb', - extnames: ['jpg', 'jpeg', 'png', 'webp'], - }) - .optional(), - labelIds: vine.array(vine.number()).optional(), + image: vine.file({ + size: '2mb', + extnames: ['jpg', 'jpeg', 'png', 'webp'], // ← Allowed types + }).optional(), + labelIds: vine.array(vine.number()).optional() }) -) +) \ No newline at end of file diff --git a/app/validators/notes/update_note_validator.ts b/app/validators/notes/update_note_validator.ts index 4b2ca3c..5d0462e 100644 --- a/app/validators/notes/update_note_validator.ts +++ b/app/validators/notes/update_note_validator.ts @@ -1,6 +1,5 @@ // app/validators/note/update_note_validator.ts import vine from '@vinejs/vine' - export const updateNoteValidator = vine.compile( vine.object({ title: vine.string().minLength(3).maxLength(255).optional(), @@ -12,6 +11,9 @@ export const updateNoteValidator = vine.compile( extnames: ['jpg', 'jpeg', 'png', 'webp'], }) .optional(), + removeImage: vine.boolean().optional(), labelIds: vine.array(vine.number()).optional(), + removeLabelIds: vine.array(vine.number()).optional() // New field + }) -) +) \ No newline at end of file diff --git a/app/validators/projects/project.ts b/app/validators/projects/project.ts new file mode 100644 index 0000000..5d2abdf --- /dev/null +++ b/app/validators/projects/project.ts @@ -0,0 +1,9 @@ +import vine from '@vinejs/vine' + +export const projectValidator = vine.compile( + vine.object({ + title: vine.string().trim().minLength(3).maxLength(255), + description: vine.string().trim().maxLength(1000).optional(), + status: vine.enum(['pending', 'in_progress', 'completed'] as const), + }) +) diff --git a/app/validators/projects/project_status.ts b/app/validators/projects/project_status.ts new file mode 100644 index 0000000..d4b4388 --- /dev/null +++ b/app/validators/projects/project_status.ts @@ -0,0 +1,7 @@ +import vine from '@vinejs/vine' + +export const projectStatusValidator = vine.compile( + vine.object({ + status: vine.enum(['pending', 'in_progress', 'completed'] as const), + }) +) diff --git a/config/shield.ts b/config/shield.ts index 7ababc0..5be6f92 100644 --- a/config/shield.ts +++ b/config/shield.ts @@ -16,7 +16,9 @@ const shieldConfig = defineConfig({ * to learn more */ csrf: { - enabled: true, + enabled: false, + // Uncomment the following line to enable CSRF protection + // we turn off to test on postman exceptRoutes: ['/notes/:id/upload'], enableXsrfCookie: true, methods: ['POST', 'PUT', 'PATCH', 'DELETE'], diff --git a/database/app.sqlite b/database/app.sqlite index b22173e71fe77592528ff05d3e1f296434febe8f..c58d17323c5286b1685938665debd21ab5509851 100644 GIT binary patch literal 61440 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 Q$3_mlRx2D@++N%N0gY@7Z~y=R delta 4342 zcmb7{du&@*9mns>kDK`3lcZS-&GP8#E?qr4_xc%kV_-XR{7P)cZ&Nh+9Vd1i+t;xj zX}v9zHjug$cDD+_A3*~l1X3o&|3KRTg7HT|6HrwM_Q%%3KkN@ARv{t8(71lA?q>Ju z5-IxWoZsjCe!p|>Ip1@9=`MQdF8Ym=6YvTIL4g1Nl}xE>-SXLu6l$!U(6efnWi z$&;tJcV?eD-cLw?Lr4z327$kWdo9J>EuaE8ik^VCOH8JUk09vh;X)#rDO4rnFggz> z(xvTu_4#UQBU4PAJE9^}Y(pE_E(W!=d?ny;dTNus8l}dqEl5(**9I(sxHot^jd6g$8OY~ zG8c=HxN1-D*HT)8nlh>B#W{*cVK1+Kfi*Ta?W=x&#Iap3*|H&% z%BBqmElQ``>&PvKthT(xnF!XyG?hz-bUyP=ZMBxOr?h@kHlV9#yg6H`Y0lXjI=#8d zglnn2U5arqMMF#%k1sP?cQamaCp@W|Dv*oCMXy+7?HRVduwF1%LJn&*px?ImEKUn! z-Ki`6i%E)(26p|~Ks~ran;XjscVn$o;A2-+29+(i)eJ6aH$1UQ#7r;7QvP^h&!(&C zYt?eNQduz=Y+idHyUs+-pc6UP;QY|-^E%uOK zX^Ry%@*CNW)jd-o9!l3X;~P!Coyj*UAy+8oNw1g!jj&3$QE_FQUboin_4qeZ27QgT z8%*v{IMZOVRwh%?mlBjAZL2RVZ7kN;#1?kslg=q_L*O!)B!5Vnh}*4GbH5lL+-`~6pxLhkAP(?ag}zDme_usF2pTk8yvzd1WwBAK61W z_AL)J5TrkyAzvrh%me>{%Ag{*vZkjpsJN3yX?iMybqiuY_4A^?UHa>-;Z>%6 z^GL+bn3tJ0FBQeJG^K6}4S&$Y%4AP!WFjZ5mUP1pm-!(VTTgrBqUz_bg`W1vMb@L? zwWqy4c!w8rv9*P$JwW7+Xde&&u0Y@l;J|ml_rQ-q3)}#|1-}CCg4^H&a1Z<&d%m z42NL}=rj?G>{GYgm=R@j{FZpxvePx diff --git a/database/migrations/1752772104618_create_create_projects_table.ts b/database/migrations/1752772104618_create_create_projects_table.ts index ada9225..708bb25 100644 --- a/database/migrations/1752772104618_create_create_projects_table.ts +++ b/database/migrations/1752772104618_create_create_projects_table.ts @@ -1,29 +1,31 @@ import { BaseSchema } from '@adonisjs/lucid/schema' -export default class extends BaseSchema { +export default class Projects extends BaseSchema { protected tableName = 'projects' - - async up() { + public async up() { this.schema.createTable(this.tableName, (table) => { table.increments('id') - table.string('title').notNullable() // updated from `name` to `title` - table.text('description').nullable() // added description + table.string('title').notNullable() + table.text('description').notNullable() table .enu('status', ['pending', 'in_progress', 'completed']) .notNullable() - .defaultTo('pending') // added status with enum - table - .integer('user_id') - .unsigned() - .references('id') - .inTable('users') - .onDelete('CASCADE') - table.timestamp('created_at', { useTz: true }).defaultTo(this.now()) - table.timestamp('updated_at', { useTz: true }).defaultTo(this.now()) + .defaultTo('pending') + + // Uncomment when auth is added + // table + // .integer('user_id') + // .unsigned() + // .references('id') + // .inTable('users') + // .onDelete('CASCADE') + + table.timestamp('created_at', { useTz: true }).notNullable() + table.timestamp('updated_at', { useTz: true }).notNullable() }) } - async down() { + public async down() { this.schema.dropTable(this.tableName) } } diff --git a/package-lock.json b/package-lock.json index 906c25e..1d923a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,9 @@ "cloudinary": "^2.7.0", "date-fns": "^4.1.0", "edge.js": "^6.2.1", - "framer-motion": "^12.4.10", + "framer-motion": "^12.23.6", + "fs-extra": "^11.3.0", + "highlight.js": "^11.11.1", "lucide-react": "^0.479.0", "luxon": "^3.5.0", "marked": "^16.1.2", @@ -47,6 +49,7 @@ "@swc/core": "^1.10.16", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", + "@types/fs-extra": "^11.0.4", "@types/jest": "^29.5.14", "@types/luxon": "^3.4.2", "@types/node": "^22.13.2", @@ -4228,6 +4231,24 @@ "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/fs-extra": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz", + "integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==", + "dev": true, + "dependencies": { + "@types/jsonfile": "*", + "@types/node": "*" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -4301,6 +4322,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonfile": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.4.tgz", + "integrity": "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/luxon": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz", @@ -7617,6 +7647,54 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, + "node_modules/fs-extra": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", + "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fs-extra/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-minipass/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, "node_modules/fs-readdir-recursive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fs-readdir-recursive/-/fs-readdir-recursive-1.1.0.tgz", @@ -7886,7 +7964,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "devOptional": true, "license": "ISC" }, "node_modules/graphemer": { @@ -9833,6 +9910,25 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonfile/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/jsonschema": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/jsonschema/-/jsonschema-1.5.0.tgz", @@ -14202,4 +14298,4 @@ } } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 1cbe73f..c158842 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@swc/core": "^1.10.16", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", + "@types/fs-extra": "^11.0.4", "@types/jest": "^29.5.14", "@types/luxon": "^3.4.2", "@types/node": "^22.13.2", @@ -84,7 +85,9 @@ "cloudinary": "^2.7.0", "date-fns": "^4.1.0", "edge.js": "^6.2.1", - "framer-motion": "^12.4.10", + "framer-motion": "^12.23.6", + "fs-extra": "^11.3.0", + "highlight.js": "^11.11.1", "lucide-react": "^0.479.0", "luxon": "^3.5.0", "marked": "^16.1.2", @@ -102,4 +105,4 @@ "eslintConfig": { "extends": "@adonisjs/eslint-config/app" } -} +} \ No newline at end of file diff --git a/start/routes.ts b/start/routes.ts index 5c4ee40..1e4c077 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -2,11 +2,9 @@ import router from '@adonisjs/core/services/router' import LabelsController from '#controllers/LabelController' - -// Controllers import NotesController from '#controllers/NoteController' - import TodosController from '#controllers/TodoController' +import ProjectsController from '#controllers/ProjectController' // Middleware const authMiddleware = () => import('#middleware/auth_middleware') @@ -20,7 +18,7 @@ router.get('/', ({ inertia }) => inertia.render('home')) // API Routes // ======================== -// Public route to view shared note (TODO: implement viewSharedNote method) +// Public route to view shared note router.get('/notes/shared/:uuid', [NotesController, 'viewSharedNote']) // Auth-protected notes routes @@ -29,7 +27,6 @@ router router.get('/', [NotesController, 'index']) router.post('/', [NotesController, 'store']) router.post('/upload', [NotesController, 'uploadImage']) - router.get('/:note_id', [NotesController, 'show']) router.put('/:note_id', [NotesController, 'update']) router.delete('/:note_id', [NotesController, 'destroy']) @@ -38,43 +35,36 @@ router router.post('/:note_id/share', [NotesController, 'generateShareLink']) }) .prefix('/notes') - .use(authMiddleware) // 🔐 protect all notes-related routes - -// ========== Modules Below Will Be Enabled Later ========== - -// 🔹 Projects -/* -import ProjectsController from '#controllers/ProjectController' -import { projectIdValidator } from '#validators/projects/project_id_validator' - -router.group(() => { - router.get('/', [ProjectsController, 'index']) - router.get('/create', [ProjectsController, 'create']) - router.post('/', [ProjectsController, 'store']) - - router.get('/:id', [ProjectsController, 'show']).use(projectIdValidator) - router.get('/:id/edit', [ProjectsController, 'edit']).use(projectIdValidator) - router.put('/:id', [ProjectsController, 'update']).use(projectIdValidator) - router.patch('/:id/status', [ProjectsController, 'updateStatus']).use(projectIdValidator) - router.delete('/:id', [ProjectsController, 'destroy']).use(projectIdValidator) -}).prefix('/projects') -*/ +//.use(authMiddleware) -// 🔹 Todos +// 🔹 Projects Routes (enabled) +router + .group(() => { + router.get('/', [ProjectsController, 'index']) + router.get('/create', [ProjectsController, 'create']) + router.post('/', [ProjectsController, 'store']) + router.get('/:id', [ProjectsController, 'show']) + router.get('/:id/edit', [ProjectsController, 'edit']) + router.put('/:id', [ProjectsController, 'update']) + router.patch('/:id/status', [ProjectsController, 'updateStatus']) + router.delete('/:id', [ProjectsController, 'destroy']) + }) + .prefix('/projects') +// .use(authMiddleware) // 🔒 Uncomment when auth is ready -// Enable Todos Routes +// 🔹 Todos Routes router .group(() => { - router.get('/', [TodosController, 'index']) // GET /todos - router.post('/', [TodosController, 'store']) // POST /todos - router.get('/:id', [TodosController, 'show']) // GET /todos/:id - router.put('/:id', [TodosController, 'update']) // PUT /todos/:id - router.delete('/:id', [TodosController, 'destroy']) // DELETE /todos/:id + router.get('/', [TodosController, 'index']) + router.post('/', [TodosController, 'store']) + router.get('/:id', [TodosController, 'show']) + router.put('/:id', [TodosController, 'update']) + router.delete('/:id', [TodosController, 'destroy']) }) .prefix('/todos') - .use(authMiddleware) +// .use(authMiddleware) // 🔒 Temporarily skipped -// 🔹 Labels +// 🔹 Labels Routes router .group(() => { router.get('/', [LabelsController, 'index']) @@ -82,4 +72,4 @@ router router.delete('/:id', [LabelsController, 'destroy']) }) .prefix('/labels') - .use(authMiddleware) +// .use(authMiddleware) // 🔒 Uncomment when auth is ready diff --git a/todos.txt b/todos.txt new file mode 100644 index 0000000..4cc017a --- /dev/null +++ b/todos.txt @@ -0,0 +1,2 @@ +one thing i have to clear about the relation between label and users. +