Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a27e144
Enhance logger configuration
hussein-m-kandil Aug 7, 2025
9a11bd6
Enhance logger usage
hussein-m-kandil Aug 7, 2025
a8b3df9
Update request-logger middleware
hussein-m-kandil Aug 7, 2025
ed15095
Include count of connected posts in image data
hussein-m-kandil Aug 7, 2025
cac9f46
Extract storage logic and refactor its env vars
hussein-m-kandil Aug 7, 2025
8b50b3a
Delete post image from DB if removed from storage
hussein-m-kandil Aug 7, 2025
c853161
Seed DB with admins to persist on non-admin cleanup
hussein-m-kandil Aug 8, 2025
0d626e4
Use custom app instance as default in test setup
hussein-m-kandil Aug 9, 2025
b58c0d9
Purge non-admin data before handling GET requests
hussein-m-kandil Aug 10, 2025
5afe4b0
Import by name from `express` module
hussein-m-kandil Aug 9, 2025
613ff0f
Change user password schema
hussein-m-kandil Aug 9, 2025
96e444d
Add guest sign-in route under users router
hussein-m-kandil Aug 9, 2025
c4fa1da
Return updated user on successful PATCH request
hussein-m-kandil Aug 11, 2025
da2c92d
Add `avatar` to `User` & Refactor PATCH user logic
hussein-m-kandil Aug 11, 2025
87dad68
Extract image schema/utils from router to mediator
hussein-m-kandil Aug 21, 2025
a585dbc
Enhance `/images` test
hussein-m-kandil Aug 23, 2025
4bac995
Accept nullable `imageData` in `Storage.uploadImage`
hussein-m-kandil Aug 23, 2025
e3b9326
Handle post image CRUD within post endpoints
hussein-m-kandil Aug 23, 2025
0dfa134
Upsert image as user avatar within `/images` route
hussein-m-kandil Aug 26, 2025
eaf5c1e
Turn off linting error for non-null assertions
hussein-m-kandil Aug 27, 2025
2f8c716
Add `Avatar` model & Refactor user/image endpoints
hussein-m-kandil Aug 27, 2025
e1c5006
Fix typo in seed script
hussein-m-kandil Aug 31, 2025
014b14f
Restrict access to all-images route to admin only
hussein-m-kandil Aug 31, 2025
ca1e91e
Refactor tags count endpoint
hussein-m-kandil Aug 31, 2025
144165e
Remove restriction on getting user by its username
hussein-m-kandil Aug 31, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ SECRET=secret
PORT=8080
ALLOWED_ORIGINS=*
MAX_FILE_SIZE_MB=2
STORAGE_KEY=storage-key
STORAGE_ROOT_DIR=public
SUPABASE_BUCKET=bucket-name
SUPABASE_ANON_KEY=public-anon-key
SUPABASE_URL=https://xyzcompany.supabase.co
SUPABASE_BUCKET_URL=https://xyzcompany.supabase.co/storage/v1/object/bucket
STORAGE_BUCKET=bucket-name
STORAGE_URL=https://xyzcompany.supabase.co
STORAGE_BUCKET_URL=https://xyzcompany.supabase.co/storage/v1/object/bucket
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export default tseslint.config({
rules: {
'@stylistic/semi': 'error',
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-unsafe-assignment': 'error',
'@typescript-eslint/no-confusing-void-expression': 'off',
'@typescript-eslint/restrict-template-expressions': 'off',
Expand Down
29 changes: 29 additions & 0 deletions prisma/migrations/20250813094856_add_user_avatar/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
Warnings:

- You are about to drop the column `author_id` on the `images` table. All the data in the column will be lost.
- A unique constraint covering the columns `[user_id]` on the table `images` will be added. If there are existing duplicate values, this will fail.
- Added the required column `owner_id` to the `images` table without a default value. This is not possible if the table is not empty.
- Made the column `bio` on table `users` required. This step will fail if there are existing NULL values in that column.

*/
-- DropForeignKey
ALTER TABLE "public"."images" DROP CONSTRAINT "images_author_id_fkey";

-- AlterTable
ALTER TABLE "public"."images" DROP COLUMN "author_id",
ADD COLUMN "owner_id" UUID NOT NULL,
ADD COLUMN "user_id" UUID;

-- AlterTable
ALTER TABLE "public"."users" ALTER COLUMN "bio" SET NOT NULL,
ALTER COLUMN "bio" SET DEFAULT '';

-- CreateIndex
CREATE UNIQUE INDEX "images_user_id_key" ON "public"."images"("user_id");

-- AddForeignKey
ALTER TABLE "public"."images" ADD CONSTRAINT "images_owner_id_fkey" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "public"."images" ADD CONSTRAINT "images_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
29 changes: 29 additions & 0 deletions prisma/migrations/20250827095023_add_avatar_model/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
Warnings:

- You are about to drop the column `user_id` on the `images` table. All the data in the column will be lost.

*/
-- DropForeignKey
ALTER TABLE "public"."images" DROP CONSTRAINT "images_user_id_fkey";

-- DropIndex
DROP INDEX "public"."images_user_id_key";

-- AlterTable
ALTER TABLE "public"."images" DROP COLUMN "user_id";

-- CreateTable
CREATE TABLE "public"."Avatar" (
"user_id" UUID NOT NULL,
"image_id" UUID NOT NULL
);

-- CreateIndex
CREATE UNIQUE INDEX "Avatar_user_id_key" ON "public"."Avatar"("user_id");

-- AddForeignKey
ALTER TABLE "public"."Avatar" ADD CONSTRAINT "Avatar_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "public"."Avatar" ADD CONSTRAINT "Avatar_image_id_fkey" FOREIGN KEY ("image_id") REFERENCES "public"."images"("id") ON DELETE CASCADE ON UPDATE CASCADE;
17 changes: 13 additions & 4 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,18 @@ datasource db {
model User {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
order Int @unique @default(autoincrement())
bio String @default("")
password String @db.Char(60)
fullname String @db.VarChar(100)
username String @unique @db.VarChar(50)
isAdmin Boolean @default(false) @map("is_admin")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
votesOnPosts VotesOnPosts[]
comments Comment[]
avatar Avatar?
images Image[]
posts Post[]
bio String?
comments Comment[]
votesOnPosts VotesOnPosts[]

@@map("users")
}
Expand Down Expand Up @@ -96,7 +97,7 @@ model VotesOnPosts {
model Image {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
order Int @unique @default(autoincrement())
ownerId String @map("author_id") @db.Uuid
ownerId String @map("owner_id") @db.Uuid
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
Expand All @@ -113,6 +114,14 @@ model Image {
yPos Int @default(0) @map("y_pos")
scale Float @default(1.0)
posts Post[]
avatars Avatar[]

@@map("images")
}

model Avatar {
userId String @unique @map("user_id") @db.Uuid
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
imageId String @map("image_id") @db.Uuid
image Image @relation(fields: [imageId], references: [id], onDelete: Cascade)
}
8 changes: 4 additions & 4 deletions prisma/seed/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ const postCount = titles.length;
const tags = [
'open_source',
'full_stack',
'javaScript',
'typeScript',
'javascript',
'typescript',
'security',
'frontend',
'software',
Expand Down Expand Up @@ -65,14 +65,14 @@ async function main() {
fullname: 'Clark Kent / Kal-El',
bio: 'From Krypton with love.',
username: 'superman',
isAdmin: false,
isAdmin: true,
},
{
password: bcrypt.hashSync(...passHashArgs),
bio: 'From Gotham with love.',
fullname: 'Bruce Wayne',
username: 'batman',
isAdmin: false,
isAdmin: true,
},
],
});
Expand Down
9 changes: 4 additions & 5 deletions src/api/v1/auth/auth.router.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
import * as Exp from 'express';
import * as Types from '@/types';
import * as Utils from '@/lib/utils';
import * as AppError from '@/lib/app-error';
import * as Validators from '@/middlewares/validators';
import { User } from '@/../prisma/client';
import { Router, RequestHandler } from 'express';
import passport from '@/lib/passport';
import logger from '@/lib/logger';

export const authRouter = Exp.Router();
export const authRouter = Router();

authRouter.post('/signin', async (req, res, next) => {
await (
passport.authenticate(
'local',
{ session: false },
(error: unknown, user: User | false | null | undefined) => {
(error: unknown, user: Types.PublicUser | false | null | undefined) => {
if (error || !user) {
if (error) logger.error(error);
next(new AppError.AppSignInError());
Expand All @@ -29,7 +28,7 @@ authRouter.post('/signin', async (req, res, next) => {
});
}
}
) as Exp.RequestHandler
) as RequestHandler
)(req, res, next);
});

Expand Down
27 changes: 9 additions & 18 deletions src/api/v1/images/image.schema.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,11 @@
import * as Image from '@/lib/image';
import { z } from 'zod';

export const posSchema = z.coerce
.number()
.optional()
.transform((n) => n && Math.trunc(n));

export const scaleSchema = z.coerce.number().optional();

export const infoSchema = z.string().trim().optional();

export const altSchema = z.string().trim().optional();

export const imageSchema = z.object({
scale: scaleSchema,
info: infoSchema,
xPos: posSchema,
yPos: posSchema,
alt: altSchema,
});
export const imageSchema = Image.imageSchema.merge(
z.object({
isAvatar: z.preprocess(
(v) => [true, 'true', 'on'].includes(v as string | boolean),
z.boolean().optional().default(false)
),
})
);
49 changes: 28 additions & 21 deletions src/api/v1/images/images.router.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
import * as Exp from 'express';
import * as Types from '@/types';
import * as Utils from '@/lib/utils';
import * as Image from '@/lib/image';
import * as Storage from '@/lib/storage';
import * as Schema from './image.schema';
import * as Service from './images.service';
import * as Middlewares from '@/middlewares';
import { Image } from '@/../prisma/client';
import { Router, Request, Response } from 'express';
import { Image as ImageT } from '@/../prisma/client';

export const imagesRouter = Exp.Router();
export const imagesRouter = Router();

imagesRouter.get('/', async (req, res) => {
res.json(
await Service.getAllImages(Utils.getPaginationFiltersFromReqQuery(req))
);
});
imagesRouter.get(
'/',
Middlewares.authValidator,
Middlewares.adminValidator,
async (req, res) => {
res.json(
await Service.getAllImages(Utils.getPaginationFiltersFromReqQuery(req))
);
}
);

imagesRouter.get('/:id', async (req, res) => {
res.json(await Service.findImageById(req.params.id));
Expand All @@ -22,14 +29,14 @@ imagesRouter.post(
'/',
Middlewares.authValidator,
Middlewares.createFileProcessor('image'),
async (req: Exp.Request, res: Exp.Response) => {
async (req: Request, res: Response) => {
const user = req.user as Types.PublicUser;
const imageFile = await Service.getValidImageFileFormReq(req);
const imageFile = await Image.getValidImageFileFormReq(req);
const data = {
...Schema.imageSchema.parse(req.body),
...Service.getImageMetadata(imageFile),
...Image.getImageMetadata(imageFile),
};
const uploadRes = await Service.uploadImage(imageFile, user);
const uploadRes = await Storage.uploadImage(imageFile, user);
const savedImage = await Service.saveImage(uploadRes, data, user);
res.status(201).json(savedImage);
}
Expand All @@ -39,24 +46,24 @@ imagesRouter.put(
'/:id',
Middlewares.authValidator,
Middlewares.createOwnerValidator(
Service.getImageOwnerAndInjectImageInResLocals
Image.getImageOwnerAndInjectImageInResLocals
),
Middlewares.createFileProcessor('image'),
async (req: Exp.Request, res: Exp.Response<unknown, { image: Image }>) => {
async (req: Request, res: Response<unknown, { image: ImageT }>) => {
const { image } = res.locals;
const user = req.user as Types.PublicUser;
if (req.file) {
const imageFile = await Service.getValidImageFileFormReq(req);
const imageFile = await Image.getValidImageFileFormReq(req);
const data = {
...Schema.imageSchema.parse(req.body),
...Service.getImageMetadata(imageFile),
...Image.getImageMetadata(imageFile),
};
const uploadRes = await Service.uploadImage(imageFile, user, image);
const uploadRes = await Storage.uploadImage(imageFile, user, image);
const savedImage = await Service.saveImage(uploadRes, data, user);
res.json(savedImage);
} else {
const data = Schema.imageSchema.parse(req.body);
const updatedImage = await Service.updateImageData(data, req.params.id);
const updatedImage = await Service.updateImageData(data, image, user);
res.json(updatedImage);
}
}
Expand All @@ -66,11 +73,11 @@ imagesRouter.delete(
'/:id',
Middlewares.authValidator,
Middlewares.createAdminOrOwnerValidator(
Service.getImageOwnerAndInjectImageInResLocals
Image.getImageOwnerAndInjectImageInResLocals
),
async (req, res: Exp.Response<unknown, { image: Image }>) => {
async (req, res: Response<unknown, { image: ImageT }>) => {
const { image } = res.locals;
await Service.removeUploadedImage(image);
await Storage.removeImage(image);
await Service.deleteImageById(image.id);
res.status(204).send();
}
Expand Down
Loading