diff --git a/app/controllers/LabelController.ts b/app/controllers/LabelController.ts new file mode 100644 index 0000000..b6d2cf9 --- /dev/null +++ b/app/controllers/LabelController.ts @@ -0,0 +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 { + // 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) + } + + // 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 /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() + } +} diff --git a/app/controllers/NoteController.ts b/app/controllers/NoteController.ts new file mode 100644 index 0000000..5ff9175 --- /dev/null +++ b/app/controllers/NoteController.ts @@ -0,0 +1,419 @@ +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 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']) { + 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 }) + } + } + + + + //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 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 this.safeAttachLabels(note, payload.labelIds) + } + + return response.created({ + message: 'Note created successfully', + note: await note.load('labels') + }) + + } catch (error) { + 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) + + // 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) { + // 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: await note.load('labels') + }) + } catch (error) { + 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 }) + 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/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 new file mode 100644 index 0000000..3204ccb --- /dev/null +++ b/app/controllers/TodoController.ts @@ -0,0 +1,109 @@ +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 /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 /todos/:id + public async show({ params, response }: HttpContext) { + try { + 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 response.ok(todo) + } catch (error) { + return response.internalServerError({ message: 'Failed to fetch todo', error: error.message }) + } + } + + // POST /todos + public async store({ request, response }: HttpContext) { + try { + const payload = await request.validateUsing(createTodoValidator) + + 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 }) + } + } + + // PUT /todos/:id + public async update({ params, request, response }: HttpContext) { + try { + const todo = await Todo.find(params.id) + + if (!todo || todo.deletedAt) { + return response.notFound({ message: 'Todo not found or deleted' }) + } + + 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 }) + } + } + + // DELETE /todos/:id + public async destroy({ params, response }: HttpContext) { + 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.ok({ message: 'Todo soft-deleted successfully' }) + + } catch (error) { + return response.internalServerError({ message: 'Failed to delete todo', error: error.message }) + } + } +} diff --git a/app/models/label.ts b/app/models/label.ts new file mode 100644 index 0000000..bad4797 --- /dev/null +++ b/app/models/label.ts @@ -0,0 +1,42 @@ +// 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' + +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 + + @belongsTo(() => User) + declare user: BelongsTo + + @manyToMany(() => Todo, { + pivotTable: 'label_todo', + pivotTimestamps: true, + }) + declare todos: ManyToMany + + @manyToMany(() => Note, { + pivotTable: 'label_note', + pivotTimestamps: true, + }) + declare notes: ManyToMany + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + 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/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/models/todo.ts b/app/models/todo.ts new file mode 100644 index 0000000..3ac6fca --- /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, // ✅ captures created_at/updated_at in pivot + }) + 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 +} 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..fcb06a7 --- /dev/null +++ b/app/validators/notes/create_note_validator.ts @@ -0,0 +1,15 @@ +// app/validators/note/create_note_validator.ts +import vine from '@vinejs/vine' + +export const createNoteValidator = vine.compile( + vine.object({ + title: vine.string().minLength(3), + content: vine.string().optional(), + pinned: vine.boolean().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/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..5d0462e --- /dev/null +++ b/app/validators/notes/update_note_validator.ts @@ -0,0 +1,19 @@ +// 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(), + 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/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/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/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..5be6f92 100644 --- a/config/shield.ts +++ b/config/shield.ts @@ -16,8 +16,10 @@ const shieldConfig = defineConfig({ * to learn more */ csrf: { - enabled: true, - exceptRoutes: [], + 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 new file mode 100644 index 0000000..c58d173 Binary files /dev/null and b/database/app.sqlite differ diff --git a/database/migrations/1741537012069_create_notes_table.ts b/database/migrations/1741537012069_create_notes_table.ts deleted file mode 100644 index 11e4e38..0000000 --- a/database/migrations/1741537012069_create_notes_table.ts +++ /dev/null @@ -1,20 +0,0 @@ -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').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..708bb25 --- /dev/null +++ b/database/migrations/1752772104618_create_create_projects_table.ts @@ -0,0 +1,31 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class Projects extends BaseSchema { + protected tableName = 'projects' + public async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id') + table.string('title').notNullable() + table.text('description').notNullable() + table + .enu('status', ['pending', 'in_progress', 'completed']) + .notNullable() + .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() + }) + } + + public async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/database/migrations/1752772224351_create_create_todos_table.ts b/database/migrations/1752772224351_create_create_todos_table.ts new file mode 100644 index 0000000..bce437c --- /dev/null +++ b/database/migrations/1752772224351_create_create_todos_table.ts @@ -0,0 +1,22 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'todos' + + async up() { + this.schema.createTable(this.tableName, (table) => { + 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') + 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/1752772229118_create_create_labels_table.ts b/database/migrations/1752772229118_create_create_labels_table.ts new file mode 100644 index 0000000..c8f9feb --- /dev/null +++ b/database/migrations/1752772229118_create_create_labels_table.ts @@ -0,0 +1,20 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'labels' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id') + table.string('name').notNullable() + table.string('color').nullable() + table.integer('user_id').unsigned().references('id').inTable('users').onDelete('CASCADE') + table.timestamp('created_at') + table.timestamp('updated_at') + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/database/migrations/1752772234396_create_create_label_todos_table.ts b/database/migrations/1752772234396_create_create_label_todos_table.ts new file mode 100644 index 0000000..f978e6d --- /dev/null +++ b/database/migrations/1752772234396_create_create_label_todos_table.ts @@ -0,0 +1,19 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'label_todo' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id') + table.integer('label_id').unsigned().references('id').inTable('labels').onDelete('CASCADE') + 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) + } +} 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 new file mode 100644 index 0000000..fcf033c --- /dev/null +++ b/database/seeders/label_seeder.ts @@ -0,0 +1,15 @@ +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', 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/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/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..1d923a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,13 +19,18 @@ "@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", + "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", "react": "^19.0.0", "react-dom": "^19.0.0", "reflect-metadata": "^0.2.2" @@ -44,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", @@ -4225,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", @@ -4298,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", @@ -4305,6 +4338,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 +5852,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", @@ -7593,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", @@ -7862,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": { @@ -9809,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", @@ -10194,6 +10314,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 +11560,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", @@ -14157,4 +14298,4 @@ } } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 9751675..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", @@ -78,13 +79,18 @@ "@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", + "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", "react": "^19.0.0", "react-dom": "^19.0.0", "reflect-metadata": "^0.2.2" @@ -99,4 +105,4 @@ "eslintConfig": { "extends": "@adonisjs/eslint-config/app" } -} +} \ No newline at end of file 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 ca1f403..1e4c077 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -1,19 +1,75 @@ -/* -|-------------------------------------------------------------------------- -| Routes file -|-------------------------------------------------------------------------- -| -| The routes file is used for defining the HTTP routes. -| -*/ - -const NotesController = () => import('#controllers/notes_controller') +// start/routes.ts import router from '@adonisjs/core/services/router' +import LabelsController from '#controllers/LabelController' +import NotesController from '#controllers/NoteController' +import TodosController from '#controllers/TodoController' +import ProjectsController from '#controllers/ProjectController' + +// Middleware +const authMiddleware = () => import('#middleware/auth_middleware') + +// ======================== +// Inertia Route (Frontend) +// ======================== router.get('/', ({ inertia }) => inertia.render('home')) -router.get('/todos', ({ inertia }) => inertia.render('todos/empty')) -router.get('/notes', [NotesController, 'index']) -router.post('/notes', [NotesController, 'store']) -router.put('/notes/:id', [NotesController, 'update']) -router.delete('/notes/:id', [NotesController, 'destroy']) +// ======================== +// API Routes +// ======================== + +// Public route to view shared note +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) + +// 🔹 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 + +// 🔹 Todos Routes +router + .group(() => { + 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) // 🔒 Temporarily skipped + +// 🔹 Labels Routes +router + .group(() => { + router.get('/', [LabelsController, 'index']) + router.post('/', [LabelsController, 'store']) + router.delete('/:id', [LabelsController, 'destroy']) + }) + .prefix('/labels') +// .use(authMiddleware) // 🔒 Uncomment when auth is ready diff --git a/tests/GeneratedTest.png b/tests/GeneratedTest.png new file mode 100644 index 0000000..9bc3750 Binary files /dev/null and b/tests/GeneratedTest.png differ diff --git a/tests/NewTest.png b/tests/NewTest.png new file mode 100644 index 0000000..6430314 Binary files /dev/null and b/tests/NewTest.png differ diff --git a/tests/Test.png b/tests/Test.png new file mode 100644 index 0000000..322d5f8 Binary files /dev/null and b/tests/Test.png differ 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/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. + 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