added attachments
This commit is contained in:
parent
9b4cbdd0e3
commit
1da74a833a
15 changed files with 695 additions and 44 deletions
|
|
@ -4,6 +4,7 @@ DATABASE_URL="postgresql://..."
|
||||||
MINIO_ACCESS_KEY=
|
MINIO_ACCESS_KEY=
|
||||||
MINIO_SECRET_KEY=
|
MINIO_SECRET_KEY=
|
||||||
MINIO_ENDPOINT_URL=
|
MINIO_ENDPOINT_URL=
|
||||||
|
MINIO_PORT=
|
||||||
MINIO_BUCKET_NAME=
|
MINIO_BUCKET_NAME=
|
||||||
BETTER_AUTH_SECRET=
|
BETTER_AUTH_SECRET=
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,16 @@ services:
|
||||||
COLLECTOR_ZIPKIN_HOST_PORT: 9411
|
COLLECTOR_ZIPKIN_HOST_PORT: 9411
|
||||||
COLLECTOR_OTLP_ENABLED: true
|
COLLECTOR_OTLP_ENABLED: true
|
||||||
ports:
|
ports:
|
||||||
- "16686:16686"
|
- "6831:6831/udp" # jaeger-agent UDP accept compact thrift protocol
|
||||||
|
- "6832:6832/udp" # jaeger-agent UDP accept binary thrift protocol
|
||||||
|
- "5778:5778" # jaeger-agent HTTP serve configs
|
||||||
|
- "16686:16686" # jaeger-query HTTP serve frontend/API
|
||||||
|
- "4317:4317" # OTLP gRPC
|
||||||
|
- "4318:4318" # OTLP HTTP
|
||||||
|
- "14250:14250" # jaeger-collector gRPC
|
||||||
|
- "14268:14268" # jaeger-collector HTTP accept spans
|
||||||
|
- "14269:14269" # jaeger-collector HTTP admin
|
||||||
|
- "9411:9411" # zipkin-collector HTTP accept spans
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:latest
|
image: postgres:latest
|
||||||
|
|
|
||||||
|
|
@ -62,19 +62,29 @@ CREATE TABLE "auth"."verification" (
|
||||||
"updated_at" timestamp
|
"updated_at" timestamp
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "note_attachments" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"title" text DEFAULT 'Untitled',
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"deleted_at" timestamp,
|
||||||
|
"file_path" text,
|
||||||
|
"note_id" text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
CREATE TABLE "note" (
|
CREATE TABLE "note" (
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
"title" text,
|
"title" text,
|
||||||
"content" text,
|
"content" text,
|
||||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
"updatedAt" timestamp,
|
"updated_at" timestamp,
|
||||||
"deletedAt" timestamp,
|
"deleted_at" timestamp,
|
||||||
"ownerId" text NOT NULL
|
"owner_id" text NOT NULL
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
ALTER TABLE "auth"."account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
ALTER TABLE "auth"."account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
ALTER TABLE "auth"."session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
ALTER TABLE "auth"."session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
ALTER TABLE "note" ADD CONSTRAINT "note_ownerId_user_id_fk" FOREIGN KEY ("ownerId") REFERENCES "auth"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
ALTER TABLE "note_attachments" ADD CONSTRAINT "note_attachments_note_id_note_id_fk" FOREIGN KEY ("note_id") REFERENCES "public"."note"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "note" ADD CONSTRAINT "note_owner_id_user_id_fk" FOREIGN KEY ("owner_id") REFERENCES "auth"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
CREATE INDEX "idx_auth_account_userid" ON "auth"."account" USING btree ("user_id");--> statement-breakpoint
|
CREATE INDEX "idx_auth_account_userid" ON "auth"."account" USING btree ("user_id");--> statement-breakpoint
|
||||||
CREATE INDEX "idx_auth_account_refreshtokenexpiresat" ON "auth"."account" USING btree ("refresh_token_expires_at");--> statement-breakpoint
|
CREATE INDEX "idx_auth_account_refreshtokenexpiresat" ON "auth"."account" USING btree ("refresh_token_expires_at");--> statement-breakpoint
|
||||||
CREATE INDEX "idx_auth_account_providerid" ON "auth"."account" USING btree ("provider_id");--> statement-breakpoint
|
CREATE INDEX "idx_auth_account_providerid" ON "auth"."account" USING btree ("provider_id");--> statement-breakpoint
|
||||||
|
|
@ -83,6 +93,8 @@ CREATE INDEX "idx_auth_session_ip_address" ON "auth"."session" USING btree ("ip_
|
||||||
CREATE INDEX "idx_auth_session_userid" ON "auth"."session" USING btree ("user_id");--> statement-breakpoint
|
CREATE INDEX "idx_auth_session_userid" ON "auth"."session" USING btree ("user_id");--> statement-breakpoint
|
||||||
CREATE INDEX "idx_auth_verification_identifier" ON "auth"."verification" USING btree ("identifier");--> statement-breakpoint
|
CREATE INDEX "idx_auth_verification_identifier" ON "auth"."verification" USING btree ("identifier");--> statement-breakpoint
|
||||||
CREATE INDEX "idx_auth_verification_expires_at" ON "auth"."verification" USING btree ("expires_at");--> statement-breakpoint
|
CREATE INDEX "idx_auth_verification_expires_at" ON "auth"."verification" USING btree ("expires_at");--> statement-breakpoint
|
||||||
CREATE INDEX "idx_note_ownerid" ON "note" USING btree ("ownerId");--> statement-breakpoint
|
CREATE INDEX "idx_attachment_attachmentId" ON "note_attachments" USING btree ("note_id");--> statement-breakpoint
|
||||||
CREATE INDEX "idx_note_createdat" ON "note" USING btree ("createdAt");--> statement-breakpoint
|
CREATE INDEX "idx_attachment_deletedat" ON "note_attachments" USING btree ("deleted_at");--> statement-breakpoint
|
||||||
CREATE INDEX "idx_note_deletedat" ON "note" USING btree ("deletedAt");
|
CREATE INDEX "idx_note_ownerid" ON "note" USING btree ("owner_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_note_createdat" ON "note" USING btree ("created_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_note_deletedat" ON "note" USING btree ("deleted_at");
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"id": "b58e6198-1838-4903-a137-a91a6605653c",
|
"id": "ef04442a-1d7f-4077-a86f-93dbe5dd3365",
|
||||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "postgresql",
|
"dialect": "postgresql",
|
||||||
|
|
@ -140,8 +140,12 @@
|
||||||
"tableFrom": "account",
|
"tableFrom": "account",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"schemaTo": "auth",
|
"schemaTo": "auth",
|
||||||
"columnsFrom": ["user_id"],
|
"columnsFrom": [
|
||||||
"columnsTo": ["id"],
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
|
|
@ -333,8 +337,12 @@
|
||||||
"tableFrom": "session",
|
"tableFrom": "session",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"schemaTo": "auth",
|
"schemaTo": "auth",
|
||||||
"columnsFrom": ["user_id"],
|
"columnsFrom": [
|
||||||
"columnsTo": ["id"],
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
|
|
@ -344,7 +352,9 @@
|
||||||
"session_token_unique": {
|
"session_token_unique": {
|
||||||
"name": "session_token_unique",
|
"name": "session_token_unique",
|
||||||
"nullsNotDistinct": false,
|
"nullsNotDistinct": false,
|
||||||
"columns": ["token"]
|
"columns": [
|
||||||
|
"token"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
|
|
@ -405,7 +415,9 @@
|
||||||
"user_email_unique": {
|
"user_email_unique": {
|
||||||
"name": "user_email_unique",
|
"name": "user_email_unique",
|
||||||
"nullsNotDistinct": false,
|
"nullsNotDistinct": false,
|
||||||
"columns": ["email"]
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
|
|
@ -492,6 +504,102 @@
|
||||||
"checkConstraints": {},
|
"checkConstraints": {},
|
||||||
"isRLSEnabled": false
|
"isRLSEnabled": false
|
||||||
},
|
},
|
||||||
|
"public.note_attachments": {
|
||||||
|
"name": "note_attachments",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "'Untitled'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"deleted_at": {
|
||||||
|
"name": "deleted_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"file_path": {
|
||||||
|
"name": "file_path",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"note_id": {
|
||||||
|
"name": "note_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"idx_attachment_attachmentId": {
|
||||||
|
"name": "idx_attachment_attachmentId",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "note_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"idx_attachment_deletedat": {
|
||||||
|
"name": "idx_attachment_deletedat",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "deleted_at",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"note_attachments_note_id_note_id_fk": {
|
||||||
|
"name": "note_attachments_note_id_note_id_fk",
|
||||||
|
"tableFrom": "note_attachments",
|
||||||
|
"tableTo": "note",
|
||||||
|
"columnsFrom": [
|
||||||
|
"note_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
"public.note": {
|
"public.note": {
|
||||||
"name": "note",
|
"name": "note",
|
||||||
"schema": "",
|
"schema": "",
|
||||||
|
|
@ -514,27 +622,27 @@
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false
|
"notNull": false
|
||||||
},
|
},
|
||||||
"createdAt": {
|
"created_at": {
|
||||||
"name": "createdAt",
|
"name": "created_at",
|
||||||
"type": "timestamp",
|
"type": "timestamp",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "now()"
|
"default": "now()"
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updated_at": {
|
||||||
"name": "updatedAt",
|
"name": "updated_at",
|
||||||
"type": "timestamp",
|
"type": "timestamp",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false
|
"notNull": false
|
||||||
},
|
},
|
||||||
"deletedAt": {
|
"deleted_at": {
|
||||||
"name": "deletedAt",
|
"name": "deleted_at",
|
||||||
"type": "timestamp",
|
"type": "timestamp",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false
|
"notNull": false
|
||||||
},
|
},
|
||||||
"ownerId": {
|
"owner_id": {
|
||||||
"name": "ownerId",
|
"name": "owner_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
|
|
@ -545,7 +653,7 @@
|
||||||
"name": "idx_note_ownerid",
|
"name": "idx_note_ownerid",
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
"expression": "ownerId",
|
"expression": "owner_id",
|
||||||
"isExpression": false,
|
"isExpression": false,
|
||||||
"asc": true,
|
"asc": true,
|
||||||
"nulls": "last"
|
"nulls": "last"
|
||||||
|
|
@ -560,7 +668,7 @@
|
||||||
"name": "idx_note_createdat",
|
"name": "idx_note_createdat",
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
"expression": "createdAt",
|
"expression": "created_at",
|
||||||
"isExpression": false,
|
"isExpression": false,
|
||||||
"asc": true,
|
"asc": true,
|
||||||
"nulls": "last"
|
"nulls": "last"
|
||||||
|
|
@ -575,7 +683,7 @@
|
||||||
"name": "idx_note_deletedat",
|
"name": "idx_note_deletedat",
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
"expression": "deletedAt",
|
"expression": "deleted_at",
|
||||||
"isExpression": false,
|
"isExpression": false,
|
||||||
"asc": true,
|
"asc": true,
|
||||||
"nulls": "last"
|
"nulls": "last"
|
||||||
|
|
@ -588,13 +696,17 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"note_ownerId_user_id_fk": {
|
"note_owner_id_user_id_fk": {
|
||||||
"name": "note_ownerId_user_id_fk",
|
"name": "note_owner_id_user_id_fk",
|
||||||
"tableFrom": "note",
|
"tableFrom": "note",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"schemaTo": "auth",
|
"schemaTo": "auth",
|
||||||
"columnsFrom": ["ownerId"],
|
"columnsFrom": [
|
||||||
"columnsTo": ["id"],
|
"owner_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@
|
||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1737390835611,
|
"when": 1737413866885,
|
||||||
"tag": "0000_soft_wraith",
|
"tag": "0000_lazy_wolfsbane",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Elysia, t } from "elysia";
|
import { Elysia, t } from "elysia";
|
||||||
import { noteRouter } from "./note/note.route";
|
import { noteRouter } from "./note/note.route";
|
||||||
|
import { attachmentRouter } from "./note/attachments/attachment.route";
|
||||||
|
|
||||||
export const router = new Elysia().use(noteRouter);
|
export const router = new Elysia().use(noteRouter).use(attachmentRouter);
|
||||||
|
|
|
||||||
169
src/api/routes/note/attachments/attachment.controller.ts
Normal file
169
src/api/routes/note/attachments/attachment.controller.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
|
import { db } from "../../../../db";
|
||||||
|
import { attachment, note } from "../../../../db/schema/note";
|
||||||
|
import { CreateAttachmentType } from "./attachment.model";
|
||||||
|
import { NoteController } from "../note.controller";
|
||||||
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
import { getSignedUrl, uploadFileAndGetUrl } from "../../../../lib/storage/storage";
|
||||||
|
import { error } from "elysia";
|
||||||
|
|
||||||
|
export class AttachmentController {
|
||||||
|
async createAttachment(new_attachment: CreateAttachmentType, ownerId:string) {
|
||||||
|
const note = new NoteController();
|
||||||
|
|
||||||
|
const existingNote = await note.getNoteById(new_attachment.noteId, ownerId);
|
||||||
|
if (existingNote.data.length===0){
|
||||||
|
throw error(403, {
|
||||||
|
success: false,
|
||||||
|
data: [],
|
||||||
|
message: "User do not have access",
|
||||||
|
error: "FORBIDDEN",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const filePath = "attachments/"+existingNote.data[0].id+"/file_"+createId()+"-"+new_attachment.file.name;
|
||||||
|
const attachmentUrl = await uploadFileAndGetUrl(filePath, new_attachment.file)
|
||||||
|
const new_attachment_data = { ...new_attachment, noteId: new_attachment.noteId, filePath:filePath };
|
||||||
|
const result = await db
|
||||||
|
.insert(attachment)
|
||||||
|
.values(new_attachment_data)
|
||||||
|
.returning({
|
||||||
|
id: attachment.id,
|
||||||
|
title: attachment.title,
|
||||||
|
noteId: attachment.noteId,
|
||||||
|
createdAt: attachment.createdAt
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
const resultWithAttachment = {
|
||||||
|
attachmentUrl:attachmentUrl,
|
||||||
|
...result[0]
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: [resultWithAttachment],
|
||||||
|
message: "Attachment created successfully",
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAttachmentsByNoteId(noteId: string, ownerId: string, limit: number = 10, offset: number = 0) {
|
||||||
|
const result = await db
|
||||||
|
.select({
|
||||||
|
id: attachment.id,
|
||||||
|
title: attachment.title,
|
||||||
|
filePath: attachment.filePath,
|
||||||
|
noteId: attachment.noteId,
|
||||||
|
createdAt: attachment.createdAt,
|
||||||
|
})
|
||||||
|
.from(attachment)
|
||||||
|
.leftJoin(note, eq(note.id, attachment.noteId))
|
||||||
|
.where(and(eq(attachment.noteId, noteId), isNull(attachment.deletedAt), eq(note.ownerId, ownerId)))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
const allAttachments = [];
|
||||||
|
for (let i = 0; i<result.length; i++){
|
||||||
|
if (!result[0].filePath){
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const attachmentUrl = await getSignedUrl(result[i].filePath as string)
|
||||||
|
const resultWithAttachment = {
|
||||||
|
attachmentUrl:attachmentUrl,
|
||||||
|
...result[i]
|
||||||
|
}
|
||||||
|
allAttachments.push(resultWithAttachment)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: allAttachments,
|
||||||
|
message: "",
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAttachmentById(attachmentId: string, ownerId: string) {
|
||||||
|
const result = await db
|
||||||
|
.select({
|
||||||
|
id: attachment.id,
|
||||||
|
title: attachment.title,
|
||||||
|
filePath: attachment.filePath,
|
||||||
|
noteId: attachment.noteId,
|
||||||
|
createdAt: attachment.createdAt,
|
||||||
|
})
|
||||||
|
.from(attachment)
|
||||||
|
.leftJoin(note, eq(note.id, attachment.noteId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(attachment.id, attachmentId),
|
||||||
|
eq(note.ownerId, ownerId),
|
||||||
|
isNull(attachment.deletedAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
if (result.length === 0 || !result[0].filePath) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
data: [],
|
||||||
|
message: "No Attachment found",
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const attachmentUrl = await getSignedUrl(result[0].filePath)
|
||||||
|
const resultWithAttachment = {
|
||||||
|
attachmentUrl:attachmentUrl,
|
||||||
|
...result[0]
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: [resultWithAttachment],
|
||||||
|
message: "",
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAttachmentById(attachmentId: string, ownerId: string) {
|
||||||
|
const existingAttachment = await this.getAttachmentById(attachmentId, ownerId)
|
||||||
|
if (existingAttachment.data.length===0){
|
||||||
|
throw error(403);
|
||||||
|
}
|
||||||
|
await db
|
||||||
|
.update(attachment)
|
||||||
|
.set({ deletedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(attachment.id, attachmentId),
|
||||||
|
isNull(attachment.deletedAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: null,
|
||||||
|
message: "Attachment deleted successfully",
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAllAttachmentsByNoteId(noteId: string, ownerId: string) {
|
||||||
|
|
||||||
|
const note = new NoteController()
|
||||||
|
const existingNote = await note.getNoteById(noteId, ownerId);
|
||||||
|
|
||||||
|
if (existingNote.data.length===0){
|
||||||
|
throw error(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(attachment)
|
||||||
|
.set({ deletedAt: new Date() })
|
||||||
|
.where(and(eq(attachment.noteId, noteId), isNull(attachment.deletedAt)))
|
||||||
|
.execute();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: null,
|
||||||
|
message: "Attachments deleted successfully",
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/api/routes/note/attachments/attachment.model.ts
Normal file
51
src/api/routes/note/attachments/attachment.model.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { createSelectSchema } from "drizzle-typebox";
|
||||||
|
import { t } from "elysia";
|
||||||
|
import { attachment } from "../../../../db/schema/note";
|
||||||
|
import { commonResponses } from "../../../../lib/utils/common";
|
||||||
|
|
||||||
|
export const _AttachmentSchema = createSelectSchema(attachment);
|
||||||
|
|
||||||
|
export const AttachmentSchema = t.Omit(_AttachmentSchema, ["deletedAt", "filePath"]);
|
||||||
|
|
||||||
|
export const AttachmentWithUrlSchema = t.Composite([ AttachmentSchema,t.Object({
|
||||||
|
attachmentUrl: t.String({default:"http://example.com/attachment_abcd"}),
|
||||||
|
})])
|
||||||
|
|
||||||
|
export const createAttachmentSchema = t.Object({
|
||||||
|
title: t.Optional(t.String()),
|
||||||
|
noteId: t.String(),
|
||||||
|
file: t.File(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type CreateAttachmentType = typeof createAttachmentSchema.static;
|
||||||
|
|
||||||
|
|
||||||
|
export const getAttachmentResponses = {
|
||||||
|
200: t.Object(
|
||||||
|
{
|
||||||
|
success: t.Boolean({ default: true }),
|
||||||
|
data: t.Array(AttachmentWithUrlSchema),
|
||||||
|
error: t.Null(),
|
||||||
|
message: t.String(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Success",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
...commonResponses,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteAttachmentResponses = {
|
||||||
|
200: t.Object(
|
||||||
|
{
|
||||||
|
success: t.Boolean({ default: true }),
|
||||||
|
data: t.Null(),
|
||||||
|
error: t.Null(),
|
||||||
|
message: t.String({ default: "Attachment deletion succesful" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Success",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
...commonResponses,
|
||||||
|
};
|
||||||
113
src/api/routes/note/attachments/attachment.route.ts
Normal file
113
src/api/routes/note/attachments/attachment.route.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { Elysia, t } from "elysia";
|
||||||
|
import {
|
||||||
|
createAttachmentSchema,
|
||||||
|
deleteAttachmentResponses,
|
||||||
|
getAttachmentResponses,
|
||||||
|
AttachmentSchema,
|
||||||
|
} from "./attachment.model";
|
||||||
|
import { AttachmentController } from "./attachment.controller";
|
||||||
|
import { userMiddleware } from "../../../../middlewares/auth-middleware";
|
||||||
|
|
||||||
|
export const attachmentRouter = new Elysia({
|
||||||
|
prefix: "/attachment",
|
||||||
|
name: "CRUD Operations for Attachments",
|
||||||
|
analytic: true,
|
||||||
|
tags: ["Attachment"],
|
||||||
|
detail: {
|
||||||
|
description: "Attachments CRUD operations",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.decorate("attachment", new AttachmentController())
|
||||||
|
.model({
|
||||||
|
attachment: AttachmentSchema,
|
||||||
|
})
|
||||||
|
.derive(({ request }) => userMiddleware(request))
|
||||||
|
.onError(({ path, error, code }) => {
|
||||||
|
console.error(error);
|
||||||
|
return {
|
||||||
|
message: path+" Error:"+code,
|
||||||
|
success: false,
|
||||||
|
data: null,
|
||||||
|
error: code.toString(),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.get(
|
||||||
|
"",
|
||||||
|
async ({ attachment, user, query }) => {
|
||||||
|
return await attachment.getAttachmentsByNoteId(query.noteId, user.id, query.limit, query.offset);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query: t.Object({
|
||||||
|
noteId:t.String(),
|
||||||
|
limit: t.Optional(t.Number()),
|
||||||
|
offset: t.Optional(t.Number()),
|
||||||
|
}),
|
||||||
|
response: getAttachmentResponses,
|
||||||
|
detail: {
|
||||||
|
description: "Get all attachments of the user",
|
||||||
|
summary: "Get all attachments",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/:id",
|
||||||
|
async ({ attachment, user, params }) => {
|
||||||
|
return await attachment.getAttachmentById(params.id, user.id);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
response: getAttachmentResponses,
|
||||||
|
detail: {
|
||||||
|
description: "Get a attachment by Id",
|
||||||
|
summary: "Get a attachment",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
"",
|
||||||
|
async ({ body, attachment, user }) => {
|
||||||
|
return await attachment.createAttachment(body, user.id);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: createAttachmentSchema,
|
||||||
|
response: getAttachmentResponses,
|
||||||
|
detail: {
|
||||||
|
description: "Create a new attachment",
|
||||||
|
summary: "Create a attachment",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.delete(
|
||||||
|
"/:id",
|
||||||
|
async ({ attachment, user, params: { id } }) => {
|
||||||
|
return await attachment.deleteAttachmentById(id, user.id);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
response: deleteAttachmentResponses,
|
||||||
|
detail: {
|
||||||
|
description: "Delete a attachment by Id",
|
||||||
|
summary: "Delete a attachment",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.delete(
|
||||||
|
"",
|
||||||
|
async ({ attachment, user, query }) => {
|
||||||
|
return await attachment.deleteAllAttachmentsByNoteId(query.noteId, user.id);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query: t.Object({
|
||||||
|
noteId:t.String(),
|
||||||
|
}),
|
||||||
|
response: deleteAttachmentResponses,
|
||||||
|
detail: {
|
||||||
|
description: "Delete all attachments of an user",
|
||||||
|
summary: "Delete all attachments",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
158
src/api/routes/note/attachments/attachment.test.ts
Normal file
158
src/api/routes/note/attachments/attachment.test.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
import { describe, expect, it, beforeAll } from "bun:test";
|
||||||
|
import { testClientApp } from "../../../../../test/client";
|
||||||
|
|
||||||
|
let attachmentId: string;
|
||||||
|
let noteId: string;
|
||||||
|
|
||||||
|
// Helper function to create a test file
|
||||||
|
const createTestFile = (content: string, filename: string = "test.txt") => {
|
||||||
|
return new File([content], filename, { type: "text/plain" });
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Attachment", () => {
|
||||||
|
// Create a note first since attachments need a noteId
|
||||||
|
beforeAll(async () => {
|
||||||
|
const { data } = await testClientApp.api.note.post({
|
||||||
|
title: "test note for attachments",
|
||||||
|
content: "description",
|
||||||
|
});
|
||||||
|
if (!data?.data) {
|
||||||
|
throw new Error("create note api did not return data");
|
||||||
|
}
|
||||||
|
noteId = data.data[0].id;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create an attachment
|
||||||
|
it("Create Attachment", async () => {
|
||||||
|
const testFile = createTestFile("Hello, World!", "test.txt");
|
||||||
|
const { data } = await testClientApp.api.attachment.post({
|
||||||
|
title: "test attachment",
|
||||||
|
file: testFile,
|
||||||
|
noteId: noteId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data?.data) {
|
||||||
|
throw new Error("create attachment api did not return data");
|
||||||
|
}
|
||||||
|
|
||||||
|
attachmentId = data.data[0].id;
|
||||||
|
expect(data.data[0].title).toBe("test attachment");
|
||||||
|
expect(data.data[0].noteId).toBe(noteId);
|
||||||
|
expect(data.data[0].attachmentUrl).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all attachments for a note
|
||||||
|
it("Get All Attachments for a Note", async () => {
|
||||||
|
const { data } = await testClientApp.api.attachment.get({
|
||||||
|
query: {
|
||||||
|
noteId: noteId,
|
||||||
|
limit: 10,
|
||||||
|
offset: 0
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(data?.success).toBe(true);
|
||||||
|
expect(data?.data).toHaveLength(1);
|
||||||
|
expect(data?.data[0].id).toBe(attachmentId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single attachment
|
||||||
|
it("Get Created Attachment", async () => {
|
||||||
|
const { data } = await testClientApp.api.attachment({ id: attachmentId }).get();
|
||||||
|
expect(data?.success).toBe(true);
|
||||||
|
expect(data?.data[0].id).toBe(attachmentId);
|
||||||
|
expect(data?.data[0].noteId).toBe(noteId);
|
||||||
|
expect(data?.data[0].attachmentUrl).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete single attachment
|
||||||
|
it("Delete Single Attachment", async () => {
|
||||||
|
// First create a new attachment to delete
|
||||||
|
const testFile = createTestFile("Delete me!", "delete.txt");
|
||||||
|
const { data: createData } = await testClientApp.api.attachment.post({
|
||||||
|
title: "attachment to delete",
|
||||||
|
file: testFile,
|
||||||
|
noteId: noteId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createData?.data) {
|
||||||
|
throw new Error("CreateData should not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteAttachmentId = createData?.data[0].id;
|
||||||
|
if (!deleteAttachmentId) {
|
||||||
|
throw new Error("Failed to receive attachmentId in delete attachment test");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the attachment
|
||||||
|
const { data: deleteData } = await testClientApp.api
|
||||||
|
.attachment({ id: deleteAttachmentId })
|
||||||
|
.delete();
|
||||||
|
expect(deleteData?.success).toBe(true);
|
||||||
|
|
||||||
|
// Verify attachment is deleted by trying to fetch it
|
||||||
|
const { data: verifyData } = await testClientApp.api
|
||||||
|
.attachment({ id: deleteAttachmentId })
|
||||||
|
.get();
|
||||||
|
expect(verifyData?.data).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete all attachments for a note
|
||||||
|
it("Delete All Attachments for a Note", async () => {
|
||||||
|
// First create multiple attachments
|
||||||
|
const file1 = createTestFile("Content 1", "file1.txt");
|
||||||
|
const file2 = createTestFile("Content 2", "file2.txt");
|
||||||
|
|
||||||
|
await testClientApp.api.attachment.post({
|
||||||
|
title: "attachment 1",
|
||||||
|
file: file1,
|
||||||
|
noteId: noteId,
|
||||||
|
});
|
||||||
|
await testClientApp.api.attachment.post({
|
||||||
|
title: "attachment 2",
|
||||||
|
file: file2,
|
||||||
|
noteId: noteId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete all attachments for the note
|
||||||
|
const { data: deleteData } = await testClientApp.api.attachment.delete({},{
|
||||||
|
query: { noteId: noteId }
|
||||||
|
});
|
||||||
|
expect(deleteData?.success).toBe(true);
|
||||||
|
|
||||||
|
// Verify all attachments are deleted
|
||||||
|
const { data: verifyData } = await testClientApp.api.attachment.get({
|
||||||
|
query: {
|
||||||
|
noteId: noteId,
|
||||||
|
limit: 10,
|
||||||
|
offset: 0
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(verifyData?.data).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Error cases
|
||||||
|
it("Should handle invalid attachment ID", async () => {
|
||||||
|
const invalidId = "invalid-id";
|
||||||
|
const { data, error } = await testClientApp.api.attachment({ id: invalidId }).get();
|
||||||
|
expect(data?.data?.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Should handle invalid note ID when creating attachment", async () => {
|
||||||
|
const invalidNoteId = "invalid-note-id";
|
||||||
|
const testFile = createTestFile("Test content", "test.txt");
|
||||||
|
const { data, error } = await testClientApp.api.attachment.post({
|
||||||
|
title: "test attachment",
|
||||||
|
file: testFile,
|
||||||
|
noteId: invalidNoteId,
|
||||||
|
});
|
||||||
|
expect(error?.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up - delete the test note after all tests
|
||||||
|
it("Clean up test note", async () => {
|
||||||
|
const { data } = await testClientApp.api.note({ id: noteId }).delete();
|
||||||
|
expect(data?.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -13,7 +13,7 @@ export type CreateNoteType = Pick<
|
||||||
"title" | "content"
|
"title" | "content"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export const createNoteSchema = t.Pick(NoteSchema, ["title", "content"]);
|
export const createNoteSchema =t.Partial(t.Pick(NoteSchema, ["title", "content"]))
|
||||||
|
|
||||||
export const getNoteResponses = {
|
export const getNoteResponses = {
|
||||||
200: t.Object(
|
200: t.Object(
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,8 @@ export const noteRouter = new Elysia({
|
||||||
)
|
)
|
||||||
.get(
|
.get(
|
||||||
"/:id",
|
"/:id",
|
||||||
async ({ note, user, params: { id } }) => {
|
async ({ note, user, params }) => {
|
||||||
return await note.getNoteById(id, user.id);
|
return await note.getNoteById(params.id, user.id);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
params: t.Object({
|
params: t.Object({
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,10 @@ export const note = pgTable(
|
||||||
.$defaultFn(() => `note_${createId()}`),
|
.$defaultFn(() => `note_${createId()}`),
|
||||||
title: text("title"),
|
title: text("title"),
|
||||||
content: text("content"),
|
content: text("content"),
|
||||||
createdAt: timestamp().notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
updatedAt: timestamp(),
|
updatedAt: timestamp("updated_at"),
|
||||||
deletedAt: timestamp(),
|
deletedAt: timestamp("deleted_at"),
|
||||||
ownerId: text()
|
ownerId: text("owner_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id),
|
.references(() => user.id),
|
||||||
},
|
},
|
||||||
|
|
@ -24,3 +24,24 @@ export const note = pgTable(
|
||||||
index("idx_note_deletedat").on(table.deletedAt),
|
index("idx_note_deletedat").on(table.deletedAt),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
export const attachment = pgTable(
|
||||||
|
"note_attachments",
|
||||||
|
{
|
||||||
|
id: text("id")
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => `attachment_${createId()}`),
|
||||||
|
title: text("title").default("Untitled"),
|
||||||
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
deletedAt: timestamp("deleted_at"),
|
||||||
|
filePath: text("file_path"),
|
||||||
|
noteId: text("note_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => note.id),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_attachment_attachmentId").on(table.noteId),
|
||||||
|
index("idx_attachment_deletedat").on(table.deletedAt),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { getMinioConfig } from "../utils/env";
|
||||||
const minioConfig = getMinioConfig();
|
const minioConfig = getMinioConfig();
|
||||||
const minioClient = new Client({
|
const minioClient = new Client({
|
||||||
endPoint: minioConfig.MINIO_ENDPOINT_URL,
|
endPoint: minioConfig.MINIO_ENDPOINT_URL,
|
||||||
|
port: +minioConfig.MINIO_PORT,
|
||||||
useSSL: false,
|
useSSL: false,
|
||||||
accessKey: minioConfig.MINIO_ACCESS_KEY,
|
accessKey: minioConfig.MINIO_ACCESS_KEY,
|
||||||
secretKey: minioConfig.MINIO_SECRET_KEY,
|
secretKey: minioConfig.MINIO_SECRET_KEY,
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ const envSchema = z.object({
|
||||||
// MinIO
|
// MinIO
|
||||||
MINIO_ACCESS_KEY: z.string(),
|
MINIO_ACCESS_KEY: z.string(),
|
||||||
MINIO_SECRET_KEY: z.string().min(8),
|
MINIO_SECRET_KEY: z.string().min(8),
|
||||||
MINIO_ENDPOINT_URL: z.string().url(),
|
MINIO_ENDPOINT_URL: z.string(),
|
||||||
|
MINIO_PORT: z.string().max(5),
|
||||||
MINIO_BUCKET_NAME: z.string(),
|
MINIO_BUCKET_NAME: z.string(),
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
|
|
@ -98,6 +99,7 @@ export const getMinioConfig = (): Pick<
|
||||||
| "MINIO_ACCESS_KEY"
|
| "MINIO_ACCESS_KEY"
|
||||||
| "MINIO_SECRET_KEY"
|
| "MINIO_SECRET_KEY"
|
||||||
| "MINIO_ENDPOINT_URL"
|
| "MINIO_ENDPOINT_URL"
|
||||||
|
| "MINIO_PORT"
|
||||||
| "MINIO_BUCKET_NAME"
|
| "MINIO_BUCKET_NAME"
|
||||||
> => {
|
> => {
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
|
|
@ -105,6 +107,7 @@ export const getMinioConfig = (): Pick<
|
||||||
MINIO_ACCESS_KEY: config.MINIO_ACCESS_KEY,
|
MINIO_ACCESS_KEY: config.MINIO_ACCESS_KEY,
|
||||||
MINIO_SECRET_KEY: config.MINIO_SECRET_KEY,
|
MINIO_SECRET_KEY: config.MINIO_SECRET_KEY,
|
||||||
MINIO_ENDPOINT_URL: config.MINIO_ENDPOINT_URL,
|
MINIO_ENDPOINT_URL: config.MINIO_ENDPOINT_URL,
|
||||||
|
MINIO_PORT: config.MINIO_PORT,
|
||||||
MINIO_BUCKET_NAME: config.MINIO_BUCKET_NAME,
|
MINIO_BUCKET_NAME: config.MINIO_BUCKET_NAME,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue