Merge pull request #2 from Sonny93/cat_link_author

Assign categories and links to their author
This commit is contained in:
Sonny
2023-06-04 01:05:27 +02:00
committed by GitHub
46 changed files with 768 additions and 533 deletions

View File

@@ -1,7 +1,7 @@
version: "3.8"
services:
my-links-db:
my-links-dev-db:
image: mysql:latest
restart: always
env_file:

View File

@@ -1,6 +1,19 @@
CONTAINER_NAME = "docker-my-links-dev-db-1"
ROOT_PASSWORD = "root_passwd"
USER_NAME = "my-user"
start-dev:
@echo 'Starting DB container'
docker compose --env-file ../.env -f ./dev.docker-compose.yml up -d
@echo 'Waiting for a minute (need to set $(USER_NAME) privileges)'
@sleep 1m
@echo 'Grant privileges for $(USER_NAME)'
docker exec -it $(CONTAINER_NAME) mysql -u root -p$(ROOT_PASSWORD) -e "grant ALL PRIVILEGES ON *.* TO '$(USER_NAME)';flush privileges;"
@echo 'Dont forget to do migrations before run dev'
start-prod:
docker-compose --env-file ../.env -f ./docker-compose.yml up -d

View File

@@ -1,7 +1,7 @@
MYSQL_USER="my_user"
MYSQL_PASSWORD="root"
MYSQL_DATABASE="my-links"
MYSQL_ROOT_PASSWORD="root"
MYSQL_USER="my-user"
MYSQL_PASSWORD="my-user_passwd"
MYSQL_ROOT_PASSWORD="root_passwd"
MYSQL_DATABASE="MyLinks"
# Or if you need external Database
# DATABASE_IP="localhost"

40
package-lock.json generated
View File

@@ -22,7 +22,8 @@
"react-select": "^5.7.3",
"sass": "^1.62.1",
"sharp": "^0.32.1",
"toastr": "^2.1.4"
"toastr": "^2.1.4",
"yup": "^1.2.0"
},
"devDependencies": {
"@types/node": "^20.2.4",
@@ -6005,6 +6006,11 @@
"react-is": "^16.13.1"
}
},
"node_modules/property-expr": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.5.tgz",
"integrity": "sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA=="
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -6811,6 +6817,11 @@
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
"dev": true
},
"node_modules/tiny-case": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
"integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q=="
},
"node_modules/tiny-glob": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz",
@@ -6848,6 +6859,11 @@
"jquery": ">=1.12.0"
}
},
"node_modules/toposort": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
"integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg=="
},
"node_modules/tsconfig-paths": {
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz",
@@ -7161,6 +7177,28 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yup": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/yup/-/yup-1.2.0.tgz",
"integrity": "sha512-PPqYKSAXjpRCgLgLKVGPA33v5c/WgEx3wi6NFjIiegz90zSwyMpvTFp/uGcVnnbx6to28pgnzp/q8ih3QRjLMQ==",
"dependencies": {
"property-expr": "^2.0.5",
"tiny-case": "^1.0.3",
"toposort": "^2.0.2",
"type-fest": "^2.19.0"
}
},
"node_modules/yup/node_modules/type-fest": {
"version": "2.19.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
"integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
"engines": {
"node": ">=12.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zod": {
"version": "3.21.4",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz",

View File

@@ -24,7 +24,8 @@
"react-select": "^5.7.3",
"sass": "^1.62.1",
"sharp": "^0.32.1",
"toastr": "^2.1.4"
"toastr": "^2.1.4",
"yup": "^1.2.0"
},
"devDependencies": {
"@types/node": "^20.2.4",

View File

@@ -0,0 +1,17 @@
/*
Warnings:
- You are about to drop the column `nextCategoryId` on the `category` table. All the data in the column will be lost.
- You are about to drop the column `nextLinkId` on the `link` table. All the data in the column will be lost.
- Added the required column `authorId` to the `category` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE `category` DROP COLUMN `nextCategoryId`,
ADD COLUMN `authorId` INTEGER NOT NULL;
-- AlterTable
ALTER TABLE `link` DROP COLUMN `nextLinkId`;
-- AddForeignKey
ALTER TABLE `category` ADD CONSTRAINT `category_authorId_fkey` FOREIGN KEY (`authorId`) REFERENCES `user`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,11 @@
/*
Warnings:
- Added the required column `authorId` to the `link` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE `link` ADD COLUMN `authorId` INTEGER NOT NULL;
-- AddForeignKey
ALTER TABLE `link` ADD CONSTRAINT `link_authorId_fkey` FOREIGN KEY (`authorId`) REFERENCES `user`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "mysql"

View File

@@ -6,14 +6,19 @@ generator client {
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
provider = "mysql"
url = env("DATABASE_URL")
shadowDatabaseUrl = env("SHADOW_DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
google_id String @unique
email String @unique
id Int @id @default(autoincrement())
google_id String @unique
email String @unique
categories Category[]
links Link[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -21,26 +26,33 @@ model User {
}
model Category {
id Int @id @default(autoincrement())
name String @unique @db.VarChar(255)
links Link[]
nextCategoryId Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id Int @id @default(autoincrement())
name String @unique @db.VarChar(255)
links Link[]
author User @relation(fields: [authorId], references: [id])
authorId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("category")
}
model Link {
id Int @id @default(autoincrement())
name String @db.VarChar(255)
url String @db.Text
id Int @id @default(autoincrement())
name String @db.VarChar(255)
url String @db.Text
category Category @relation(fields: [categoryId], references: [id])
categoryId Int
nextLinkId Int @default(0)
favorite Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
author User @relation(fields: [authorId], references: [id])
authorId Int
favorite Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("link")
}

View File

@@ -21,6 +21,7 @@ interface FormProps {
classBtnConfirm?: string;
children: any;
disableHomeLink?: boolean;
}
export default function Form({
title,
@@ -33,6 +34,7 @@ export default function Form({
textBtnConfirm = "Valider",
classBtnConfirm = "",
children,
disableHomeLink = false,
}: FormProps) {
return (
<>
@@ -49,9 +51,11 @@ export default function Form({
{textBtnConfirm}
</button>
</form>
<Link href={categoryId ? `/?categoryId=${categoryId}` : "/"}>
Revenir à l'accueil
</Link>
{!disableHomeLink && (
<Link href={categoryId ? `/?categoryId=${categoryId}` : "/"}>
Revenir à l'accueil
</Link>
)}
<MessageManager
info={infoMessage}
error={errorMessage}

View File

@@ -31,7 +31,7 @@ export default function LinkItem({
categoryId: link.category.id,
};
axios
.put(`${PATHS.API.LINK.EDIT}/${link.id}`, payload)
.put(`${PATHS.API.LINK}/${link.id}`, payload)
.then(() => toggleFavorite(link.id))
.catch(console.error);
};

View File

@@ -1,5 +1,6 @@
const PATHS = {
LOGIN: "/signin",
LOGOUT: "/signout",
HOME: "/",
CATEGORY: {
CREATE: "/category/create",
@@ -12,16 +13,8 @@ const PATHS = {
REMOVE: "/link/remove",
},
API: {
CATEGORY: {
CREATE: "/category/create",
EDIT: "/category/edit",
REMOVE: "/category/remove",
},
LINK: {
CREATE: "/link/create",
EDIT: "/link/edit",
REMOVE: "/link/remove",
},
CATEGORY: "/api/category",
LINK: "/api/link",
},
NOT_FOUND: "/404",
SERVER_ERROR: "/505",

2
src/constants/url.ts Normal file
View File

@@ -0,0 +1,2 @@
export const VALID_URL_REGEX =
/^(?:http(s)?:\/\/)[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.\%]+$/;

93
src/lib/api/handler.ts Normal file
View File

@@ -0,0 +1,93 @@
import { User } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
import { NextApiRequest, NextApiResponse } from "next";
import { Session } from "next-auth";
import getUserOrThrow from "lib/user/getUserOrThrow";
import { getSessionOrThrow } from "utils/session";
type ApiHandlerMethod = ({
req,
res,
session,
user,
}: {
req: NextApiRequest;
res: NextApiResponse;
session: Session;
user: User;
}) => Promise<void>;
// This API Handler strongly inspired by
// Source: https://jasonwatmore.com/next-js-13-middleware-for-authentication-and-error-handling-on-api-routes
export function apiHandler(handler: {
get?: ApiHandlerMethod;
post?: ApiHandlerMethod;
put?: ApiHandlerMethod;
patch?: ApiHandlerMethod;
delete?: ApiHandlerMethod;
}) {
return async (req: NextApiRequest, res: NextApiResponse) => {
const method = req.method.toLowerCase();
if (!handler[method])
return res
.status(405)
.json({ error: `Method ${req.method} Not Allowed` });
try {
const session = await getSessionOrThrow(req, res);
const user = await getUserOrThrow(session);
await handler[method]({ req, res, session, user });
} catch (err) {
errorHandler(err, res);
}
};
}
function errorHandler(error: any, response: NextApiResponse) {
if (typeof error === "string") {
const is404 = error.toLowerCase().endsWith("not found");
const statusCode = is404 ? 404 : 400;
return response.status(statusCode).json({ message: error });
}
// does not fit with current error throwed
// TODO: fix errors returned
// by getSessionOrThrow or getUserOrThrow
if (error.name === "UnauthorizedError") {
// authentication error
return response.status(401).json({ message: "You must be connected" });
}
const errorMessage =
error.constructor.name === "PrismaClientKnownRequestError"
? handlePrismaError(error) // Handle Prisma specific errors
: error.message;
console.error(error);
return response.status(500).json({ message: errorMessage });
}
function handlePrismaError({
meta,
code,
message,
}: PrismaClientKnownRequestError) {
switch (code) {
case "P2002":
return `Duplicate field value: ${meta.target}`;
case "P2003":
return `Foreign key constraint failed on the field: ${meta.field_name}`;
case "P2014":
return `Invalid ID: ${meta.target}`;
case "P2003":
return `Invalid input data: ${meta.target}`;
// Details should not leak to client, be carreful with this
default:
return `Something went wrong: ${message}`;
}
}

View File

@@ -0,0 +1,17 @@
import { User } from "@prisma/client";
import prisma from "utils/prisma";
export default async function getUserCategories(user: User) {
return await prisma.category.findMany({
where: {
authorId: user?.id,
},
include: {
links: {
where: {
authorId: user?.id,
},
},
},
});
}

View File

@@ -0,0 +1,10 @@
import { User } from "@prisma/client";
import prisma from "utils/prisma";
export default async function getUserCategoriesCount(user: User) {
return await prisma.category.count({
where: {
authorId: user.id,
},
});
}

View File

@@ -0,0 +1,18 @@
import { Category, User } from "@prisma/client";
import prisma from "utils/prisma";
export default async function getUserCategory(user: User, id: Category["id"]) {
return await prisma.category.findFirst({
where: {
authorId: user?.id,
id,
},
include: {
links: {
where: {
authorId: user?.id,
},
},
},
});
}

View File

@@ -0,0 +1,11 @@
import { Category, User } from "@prisma/client";
import prisma from "utils/prisma";
export default async function getUserCategoryByName(
user: User,
name: Category["name"]
) {
return await prisma.category.findFirst({
where: { name, authorId: user.id },
});
}

View File

@@ -0,0 +1,19 @@
import { Category, Link, User } from "@prisma/client";
import prisma from "utils/prisma";
export default async function getLinkFromCategoryByName(
user: User,
name: Link["name"],
categoryId: Category["id"]
) {
return await prisma.link.findFirst({
where: {
authorId: user.id,
name,
categoryId,
},
include: {
category: true,
},
});
}

View File

@@ -0,0 +1,14 @@
import { Link, User } from "@prisma/client";
import prisma from "utils/prisma";
export default async function getUserLink(user: User, id: Link["id"]) {
return await prisma.link.findFirst({
where: {
id,
authorId: user.id,
},
include: {
category: true,
},
});
}

View File

@@ -0,0 +1,10 @@
import { User } from "@prisma/client";
import prisma from "utils/prisma";
export default async function getUserLinks(user: User) {
return await prisma.link.findMany({
where: {
authorId: user.id,
},
});
}

10
src/lib/user/getUser.ts Normal file
View File

@@ -0,0 +1,10 @@
import { Session } from "next-auth";
import prisma from "utils/prisma";
export default async function getUser(session: Session) {
return await prisma.user.findFirst({
where: {
email: session?.user?.email,
},
});
}

View File

@@ -0,0 +1,13 @@
import { Session } from "next-auth";
import prisma from "utils/prisma";
export default async function getUserOrThrow(session: Session) {
if (!session || session === null) {
throw new Error("You must be connected");
}
return await prisma.user.findFirstOrThrow({
where: {
email: session?.user?.email,
},
});
}

View File

@@ -7,6 +7,7 @@ import { useHotkeys } from "react-hotkeys-hook";
import AuthRequired from "components/AuthRequired";
import * as Keys from "constants/keys";
import PATHS from "constants/paths";
import "nprogress/nprogress.css";
import "styles/globals.scss";
@@ -14,8 +15,8 @@ import "styles/globals.scss";
function MyApp({ Component, pageProps: { session, ...pageProps } }) {
const router = useRouter();
useHotkeys(Keys.CLOSE_SEARCH_KEY, () => router.push("/"), {
enabled: router.pathname !== "/",
useHotkeys(Keys.CLOSE_SEARCH_KEY, () => router.push(PATHS.HOME), {
enabled: router.pathname !== PATHS.HOME,
enableOnFormTags: ["INPUT"],
});

View File

@@ -1,11 +1,12 @@
import { PrismaClient } from "@prisma/client";
import PATHS from "constants/paths";
import NextAuth from "next-auth";
import NextAuth, { NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google";
const prisma = new PrismaClient();
export default NextAuth({
// TODO: refactor auth
export const authOptions = {
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID,
@@ -20,24 +21,34 @@ export default NextAuth({
}),
],
callbacks: {
async session({ session }) {
// check if stored in session still exist in db
await prisma.user.findFirstOrThrow({
where: { email: session.user.email },
});
return session;
},
async signIn({ account: accountParam, profile }) {
// TODO: Auth
console.log(
"Connexion via",
accountParam.provider,
accountParam.providerAccountId,
profile.email,
profile.name
"[AUTH]",
"User",
profile.name,
profile.sub,
"attempt to log in with",
accountParam.provider
);
if (accountParam.provider !== "google") {
console.log("[AUTH]", "User", profile.name, "rejeced : bad provider");
return (
PATHS.LOGIN +
"?error=" +
encodeURI("Authentitifcation via Google requise")
);
}
const email = profile?.email;
if (email === "") {
console.log("[AUTH]", "User", profile.name, "rejeced : missing email");
return (
PATHS.LOGIN +
"?error=" +
@@ -48,6 +59,12 @@ export default NextAuth({
}
const googleId = profile?.sub;
if (googleId === "") {
console.log(
"[AUTH]",
"User",
profile.name,
"rejeced : missing google id"
);
return (
PATHS.LOGIN +
"?error=" +
@@ -74,6 +91,13 @@ export default NextAuth({
});
return true;
}
console.log(
"[AUTH]",
"User",
profile.name,
"rejeced : not authorized"
);
return (
PATHS.LOGIN +
"?error=" +
@@ -82,9 +106,11 @@ export default NextAuth({
)
);
} else {
console.log("[AUTH]", "User", profile.name, "success");
return true;
}
} catch (error) {
console.log("[AUTH]", "User", profile.name, "unhandled error");
console.error(error);
return (
PATHS.LOGIN +
@@ -97,8 +123,10 @@ export default NextAuth({
pages: {
signIn: PATHS.LOGIN,
error: PATHS.LOGIN,
signOut: PATHS.LOGOUT,
},
session: {
maxAge: 60 * 60 * 6, // Session de 6 heures
},
});
} as NextAuthOptions;
export default NextAuth(authOptions);

View File

@@ -0,0 +1,71 @@
import { number, object, string } from "yup";
import { apiHandler } from "lib/api/handler";
import getUserCategory from "lib/category/getUserCategory";
import prisma from "utils/prisma";
import getUserCategoryByName from "lib/category/getUserCategoryByName";
export default apiHandler({
put: editCategory,
delete: deleteCategory,
});
const querySchema = object({
cid: number().required(),
});
const bodySchema = object({
name: string()
.trim()
.required("name is required")
.max(32, "name is too long"),
}).typeError("Missing request Body");
async function editCategory({ req, res, user }) {
const { cid } = await querySchema.validate(req.query);
const { name } = await bodySchema.validate(req.body);
const category = await getUserCategory(user, cid);
if (!category) {
throw new Error("Unable to find category " + cid);
}
const isCategoryNameAlreadyused = await getUserCategoryByName(user, name);
if (isCategoryNameAlreadyused) {
throw new Error("Category name already used");
}
if (category.name === name) {
throw new Error("New category name must be different");
}
await prisma.category.update({
where: { id: cid },
data: { name },
});
return res.send({
success: "Category successfully updated",
categoryId: category.id,
});
}
async function deleteCategory({ req, res, user }) {
const { cid } = await querySchema.validate(req.query);
const category = await getUserCategory(user, cid);
if (!category) {
throw new Error("Unable to find category " + cid);
}
if (category.links.length !== 0) {
throw new Error("You cannot remove category with links");
}
await prisma.category.delete({
where: { id: cid },
});
return res.send({
success: "Category successfully deleted",
categoryId: category.id,
});
}

View File

@@ -1,47 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import prisma from "utils/prisma";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const name = req.body?.name as string;
if (!name) {
return res.status(400).send({ error: "Nom de catégorie manquant" });
}
try {
const category = await prisma.category.findFirst({
where: { name },
});
if (category) {
return res
.status(400)
.send({ error: "Une catégorie avec ce nom existe déjà" });
}
} catch (error) {
console.error(error);
return res.status(400).send({
error:
"Une erreur est survenue lors de la création de la catégorie (category/create->findCategory)",
});
}
try {
const category = await prisma.category.create({
data: { name },
});
return res.status(200).send({
success: "Catégorie créée avec succès",
categoryId: category.id,
});
} catch (error) {
console.error(error);
return res.status(400).send({
error:
"Une erreur est survenue lors de la création de la catégorie (category/create->createCategory)",
});
}
}

View File

@@ -1,53 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import prisma from "utils/prisma";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { cid } = req.query;
let category;
try {
category = await prisma.category.findFirst({
where: { id: Number(cid) },
});
if (!category) {
return res.status(400).send({ error: "Catégorie introuvable" });
}
} catch (error) {
console.error(error);
return res.status(400).send({
error:
"Une erreur est survenue lors de l'édition de la catégorie (category/edit->findCategory)",
});
}
const name = req.body?.name as string;
if (!name) {
return res.status(400).send({ error: "Nom de la catégorie manquante" });
} else if (name === category.name) {
return res.status(400).send({
error: "Le nom de la catégorie doit être différent du nom actuel",
});
}
try {
const category = await prisma.category.update({
where: { id: Number(cid) },
data: { name },
});
return res.status(200).send({
success: "Catégorie mise à jour avec succès",
categoryId: category.id,
});
} catch (error) {
console.error(error);
return res.status(400).send({
error:
"Une erreur est survenue lors de l'édition de la catégorie (category/edit->updateCategory)",
});
}
}

View File

@@ -1,20 +1,41 @@
import { NextApiRequest, NextApiResponse } from "next";
import { apiHandler } from "lib/api/handler";
import getUserCategories from "lib/category/getUserCategories";
import getUserCategoryByName from "lib/category/getUserCategoryByName";
import prisma from "utils/prisma";
import { object, string } from "yup";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
const categories = await prisma.category.findMany();
console.log("request");
return res.status(200).send({
categories,
});
} catch (error) {
console.error(error);
return res.status(400).send({
error: "Une erreur est survenue lors de la récupération des catégories",
});
}
export default apiHandler({
get: getCatgories,
post: createCategory,
});
async function getCatgories({ res, user }) {
const categories = await getUserCategories(user);
return res.status(200).send({
categories,
});
}
const bodySchema = object({
name: string()
.trim()
.required("name is required")
.max(32, "name is too long"),
}).typeError("Missing request Body");
async function createCategory({ req, res, user }) {
const { name } = await bodySchema.validate(req.body);
const category = await getUserCategoryByName(user, name);
if (category) {
throw new Error("Category name already used");
}
const categoryCreated = await prisma.category.create({
data: { name, authorId: user.id },
});
return res.status(200).send({
success: "Category successfully created",
categoryId: categoryCreated.id,
});
}

View File

@@ -1,41 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import prisma from "utils/prisma";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { cid } = req.query;
try {
const category = await prisma.category.findFirst({
where: { id: Number(cid) },
});
if (!category) {
return res.status(400).send({ error: "Categorie introuvable" });
}
} catch (error) {
console.error(error);
return res.status(400).send({
error:
"Une erreur est survenue lors de la suppression de la catégorie (category/remove->findCategory)",
});
}
try {
await prisma.category.delete({
where: { id: Number(cid) },
});
return res
.status(200)
.send({ success: "La catégorie a été supprimée avec succès" });
} catch (error) {
console.error(error);
return res.status(400).send({
error:
"Une erreur est survenue lors de la suppression de la catégorie (category/remove->deleteCategory)",
});
}
}

View File

@@ -0,0 +1,82 @@
import { boolean, number, object, string } from "yup";
import { VALID_URL_REGEX } from "constants/url";
import { apiHandler } from "lib/api/handler";
import getUserLink from "lib/link/getUserLink";
import prisma from "utils/prisma";
export default apiHandler({
put: editLink,
delete: deleteLink,
});
const querySchema = object({
lid: number().required(),
});
// FIXME: code duplicated from api/link/create
const bodySchema = object({
name: string()
.trim()
.required("name is required")
.max(32, "name is too long"),
url: string()
.trim()
.required("url is required")
.matches(VALID_URL_REGEX, "invalid url format"),
categoryId: number().required("categoryId must be a number"),
favorite: boolean().default(() => false),
}).typeError("Missing request Body");
async function editLink({ req, res, user }) {
const { lid } = await querySchema.validate(req.query);
const { name, url, favorite, categoryId } = await bodySchema.validate(
req.body
);
const link = await getUserLink(user, lid);
if (!link) {
throw new Error("Unable to find link " + lid);
}
if (
link.name === name &&
link.url === url &&
link.favorite === favorite &&
link.categoryId === categoryId
) {
throw new Error("You must update at least one field");
}
await prisma.link.update({
where: { id: Number(lid) },
data: {
name,
url,
favorite,
categoryId,
},
});
return res
.status(200)
.send({ success: "Link successfully updated", categoryId });
}
async function deleteLink({ req, res, user }) {
const { lid } = await querySchema.validate(req.query);
const link = await getUserLink(user, lid);
if (!link) {
throw new Error("Unable to find link " + lid);
}
await prisma.link.delete({
where: { id: Number(lid) },
});
return res.send({
success: "Link successfully deleted",
categoryId: link.categoryId,
});
}

View File

@@ -1,78 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import prisma from "utils/prisma";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const name = req.body?.name as string;
const url = req.body?.url as string;
const favorite = Boolean(req.body?.favorite) || false;
const categoryId = Number(req.body?.categoryId);
if (!name) {
return res.status(400).send({ error: "Nom du lien manquant" });
}
if (!url) {
return res.status(400).send({ error: "URL du lien manquant" });
}
if (!categoryId) {
return res.status(400).send({ error: "Catégorie du lien manquante" });
}
try {
const link = await prisma.link.findFirst({
where: { name, categoryId },
});
if (link) {
return res.status(400).send({
error: "Un lien avec ce nom existe déjà dans cette catégorie",
});
}
} catch (error) {
console.error(error);
return res.status(400).send({
error:
"Une erreur est survenue lors de la création du lien (link/create->findLink)",
});
}
try {
const category = await prisma.category.findFirst({
where: { id: categoryId },
});
if (!category) {
return res.status(400).send({ error: "Cette catégorie n'existe pas" });
}
} catch (error) {
console.error(error);
return res.status(400).send({
error:
"Une erreur est survenue lors de la création du lien (link/create->findCategory)",
});
}
try {
await prisma.link.create({
data: {
name,
url,
categoryId,
favorite,
},
});
return res
.status(200)
.send({ success: "Lien créé avec succès", categoryId });
} catch (error) {
console.error(error);
return res.status(400).send({
error:
"Une erreur est survenue lors de la création du lien (link/create->createLink)",
});
}
}

View File

@@ -1,65 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import prisma from "utils/prisma";
// TODO: Ajouter vérification -> l'utilisateur doit changer au moins un champ
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { lid } = req.query;
try {
const link = await prisma.link.findFirst({
where: { id: Number(lid) },
});
if (!link) {
return res.status(400).send({ error: "Lien introuvable" });
}
} catch (error) {
console.error(error);
return res.status(400).send({
error:
"Une erreur est survenue lors de l'édition du lien (link/edit->findLink)",
});
}
const name = req.body?.name as string;
const url = req.body?.url as string;
const favorite = Boolean(req.body?.favorite) || false;
const categoryId = Number(req.body?.categoryId);
if (!name) {
return res.status(400).send({ error: "Nom du lien manquant" });
}
if (!url) {
return res.status(400).send({ error: "URL du lien manquant" });
}
if (!categoryId) {
return res.status(400).send({ error: "Catégorie du lien manquante" });
}
try {
await prisma.link.update({
where: { id: Number(lid) },
data: {
name,
url,
favorite,
categoryId,
},
});
return res
.status(200)
.send({ success: "Lien mis à jour avec succès", categoryId });
} catch (error) {
console.error(error);
return res.status(400).send({
error:
"Une erreur est survenue lors de l'édition du lien (link/remove->updateLink)",
});
}
}

View File

@@ -0,0 +1,53 @@
import { boolean, number, object, string } from "yup";
import { apiHandler } from "lib/api/handler";
import getUserCategory from "lib/category/getUserCategory";
import getUserLinkByName from "lib/link/getLinkFromCategoryByName";
import { VALID_URL_REGEX } from "constants/url";
import prisma from "utils/prisma";
export default apiHandler({
post: createLink,
});
const bodySchema = object({
name: string()
.trim()
.required("name is required")
.max(32, "name is too long"),
url: string()
.trim()
.required("url is required")
.matches(VALID_URL_REGEX, "invalid url format"),
categoryId: number().required("categoryId must be a number"),
favorite: boolean().default(() => false),
}).typeError("Missing request Body");
async function createLink({ req, res, user }) {
const { name, url, favorite, categoryId } = await bodySchema.validate(
req.body
);
const link = await getUserLinkByName(user, name, categoryId);
if (link) {
throw new Error("Link name is already used in this category");
}
const category = await getUserCategory(user, categoryId);
if (!category) {
throw new Error("Unable to find category " + categoryId);
}
await prisma.link.create({
data: {
name,
url,
categoryId,
favorite,
authorId: user.id,
},
});
return res.send({ success: "Link successfully created", categoryId });
}

View File

@@ -1,42 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import prisma from "utils/prisma";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { lid } = req.query;
try {
const link = await prisma.link.findFirst({
where: { id: Number(lid) },
});
if (!link) {
return res.status(400).send({ error: "Lien introuvable" });
}
} catch (error) {
console.error(error);
return res.status(400).send({
error:
"Une erreur est survenue lors de la suppression du lien (link/remove->findLink)",
});
}
try {
const link = await prisma.link.delete({
where: { id: Number(lid) },
});
return res.status(200).send({
success: "Le lien a été supprimé avec succès",
categoryId: link.categoryId,
});
} catch (error) {
console.error(error);
return res.status(400).send({
error:
"Une erreur est survenue lors de la suppression du lien (link/remove->deleteLink)",
});
}
}

View File

@@ -7,13 +7,17 @@ import FormLayout from "components/FormLayout";
import PageTransition from "components/PageTransition";
import TextBox from "components/TextBox";
import PATHS from "constants/paths";
import useAutoFocus from "hooks/useAutoFocus";
import { redirectWithoutClientCache } from "utils/client";
import { HandleAxiosError } from "utils/front";
import getUserCategoriesCount from "lib/category/getUserCategoriesCount";
import getUser from "lib/user/getUser";
import styles from "styles/create.module.scss";
import { getSession } from "utils/session";
function CreateCategory() {
function CreateCategory({ categoriesCount }: { categoriesCount: number }) {
const autoFocusRef = useAutoFocus();
const router = useRouter();
const info = useRouter().query?.info as string;
@@ -35,9 +39,9 @@ function CreateCategory() {
nProgress.start();
try {
const { data } = await axios.post("/api/category/create", { name });
const { data } = await axios.post(PATHS.API.CATEGORY, { name });
redirectWithoutClientCache(router, "");
router.push(`/?categoryId=${data?.categoryId}`);
router.push(`${PATHS.HOME}?categoryId=${data?.categoryId}`);
setSubmitted(true);
} catch (error) {
setError(HandleAxiosError(error));
@@ -55,6 +59,7 @@ function CreateCategory() {
infoMessage={info}
canSubmit={canSubmit}
handleSubmit={handleSubmit}
disableHomeLink={categoriesCount === 0}
>
<TextBox
name="name"
@@ -72,3 +77,15 @@ function CreateCategory() {
CreateCategory.authRequired = true;
export default CreateCategory;
export async function getServerSideProps({ req, res }) {
const session = await getSession(req, res);
const user = await getUser(session);
const categoriesCount = await getUserCategoriesCount(user);
return {
props: {
categoriesCount,
},
};
}

View File

@@ -7,10 +7,13 @@ import FormLayout from "components/FormLayout";
import PageTransition from "components/PageTransition";
import TextBox from "components/TextBox";
import PATHS from "constants/paths";
import useAutoFocus from "hooks/useAutoFocus";
import getUserCategory from "lib/category/getUserCategory";
import getUser from "lib/user/getUser";
import { Category } from "types";
import { BuildCategory, HandleAxiosError } from "utils/front";
import prisma from "utils/prisma";
import { HandleAxiosError } from "utils/front";
import { getSession } from "utils/session";
import styles from "styles/create.module.scss";
@@ -35,12 +38,10 @@ function EditCategory({ category }: { category: Category }) {
nProgress.start();
try {
const payload = { name };
const { data } = await axios.put(
`/api/category/edit/${category.id}`,
payload
);
router.push(`/?categoryId=${data?.categoryId}`);
const { data } = await axios.put(`${PATHS.API.CATEGORY}/${category.id}`, {
name,
});
router.push(`${PATHS.HOME}?categoryId=${data?.categoryId}`);
setSubmitted(true);
} catch (error) {
setError(HandleAxiosError(error));
@@ -75,22 +76,21 @@ function EditCategory({ category }: { category: Category }) {
EditCategory.authRequired = true;
export default EditCategory;
export async function getServerSideProps({ query }) {
export async function getServerSideProps({ req, res, query }) {
const { cid } = query;
const categoryDB = await prisma.category.findFirst({
where: { id: Number(cid) },
include: { links: true },
});
if (!categoryDB) {
const session = await getSession(req, res);
const user = await getUser(session);
const category = await getUserCategory(user, Number(cid));
if (!category) {
return {
redirect: {
destination: "/",
destination: PATHS.HOME,
},
};
}
const category = BuildCategory(categoryDB);
return {
props: {
category: JSON.parse(JSON.stringify(category)),

View File

@@ -8,9 +8,12 @@ import FormLayout from "components/FormLayout";
import PageTransition from "components/PageTransition";
import TextBox from "components/TextBox";
import PATHS from "constants/paths";
import getUserCategory from "lib/category/getUserCategory";
import getUser from "lib/user/getUser";
import { Category } from "types";
import { BuildCategory, HandleAxiosError } from "utils/front";
import prisma from "utils/prisma";
import { HandleAxiosError } from "utils/front";
import { getSession } from "utils/session";
import styles from "styles/create.module.scss";
@@ -36,8 +39,8 @@ function RemoveCategory({ category }: { category: Category }) {
nProgress.start();
try {
await axios.delete(`/api/category/remove/${category.id}`);
router.push("/");
await axios.delete(`${PATHS.API.CATEGORY}/${category.id}`);
router.push(PATHS.HOME);
setSubmitted(true);
} catch (error) {
setError(HandleAxiosError(error));
@@ -80,22 +83,21 @@ function RemoveCategory({ category }: { category: Category }) {
RemoveCategory.authRequired = true;
export default RemoveCategory;
export async function getServerSideProps({ query }) {
export async function getServerSideProps({ req, res, query }) {
const { cid } = query;
const categoryDB = await prisma.category.findFirst({
where: { id: Number(cid) },
include: { links: true },
});
if (!categoryDB) {
const session = await getSession(req, res);
const user = await getUser(session);
const category = await getUserCategory(user, Number(cid));
if (!category) {
return {
redirect: {
destination: "/",
destination: PATHS.HOME,
},
};
}
const category = BuildCategory(categoryDB);
return {
props: {
category: JSON.parse(JSON.stringify(category)),

View File

@@ -13,9 +13,10 @@ import PATHS from "constants/paths";
import useModal from "hooks/useModal";
import { Category, Link, SearchItem } from "types";
import { BuildCategory } from "utils/front";
import getUserCategories from "lib/category/getUserCategories";
import getUser from "lib/user/getUser";
import { pushStateVanilla } from "utils/link";
import prisma from "utils/prisma";
import { getSession } from "utils/session";
interface HomePageProps {
categories: Category[];
@@ -185,13 +186,13 @@ function Home(props: HomePageProps) {
);
}
export async function getServerSideProps({ query }) {
export async function getServerSideProps({ req, res, query }) {
const session = await getSession(req, res);
const queryCategoryId = (query?.categoryId as string) || "";
const categoriesDB = await prisma.category.findMany({
include: { links: true },
});
if (categoriesDB.length === 0) {
const user = await getUser(session);
const categories = await getUserCategories(user);
if (categories.length === 0) {
return {
redirect: {
destination: PATHS.CATEGORY.CREATE,
@@ -199,11 +200,9 @@ export async function getServerSideProps({ query }) {
};
}
const categories = categoriesDB.map(BuildCategory);
const currentCategory = categories.find(
({ id }) => id === Number(queryCategoryId)
);
return {
props: {
categories: JSON.parse(JSON.stringify(categories)),

View File

@@ -9,10 +9,13 @@ import PageTransition from "components/PageTransition";
import Selector from "components/Selector";
import TextBox from "components/TextBox";
import PATHS from "constants/paths";
import useAutoFocus from "hooks/useAutoFocus";
import getUserCategories from "lib/category/getUserCategories";
import getUser from "lib/user/getUser";
import { Category, Link } from "types";
import { BuildCategory, HandleAxiosError, IsValidURL } from "utils/front";
import prisma from "utils/prisma";
import { HandleAxiosError, IsValidURL } from "utils/front";
import { getSession } from "utils/session";
import styles from "styles/create.module.scss";
@@ -49,8 +52,8 @@ function CreateLink({ categories }: { categories: Category[] }) {
try {
const payload = { name, url, favorite, categoryId };
const { data } = await axios.post("/api/link/create", payload);
router.push(`/?categoryId=${data?.categoryId}`);
const { data } = await axios.post(PATHS.API.LINK, payload);
router.push(`${PATHS.HOME}?categoryId=${data?.categoryId}`);
setSubmitted(true);
} catch (error) {
setError(HandleAxiosError(error));
@@ -110,16 +113,15 @@ function CreateLink({ categories }: { categories: Category[] }) {
CreateLink.authRequired = true;
export default CreateLink;
export async function getServerSideProps() {
const categoriesDB = await prisma.category.findMany();
const categories = categoriesDB.map((categoryDB) =>
BuildCategory(categoryDB)
);
export async function getServerSideProps({ req, res }) {
const session = await getSession(req, res);
const user = await getUser(session);
const categories = await getUserCategories(user);
if (categories.length === 0) {
return {
redirect: {
destination: "/",
destination: PATHS.HOME,
},
};
}

View File

@@ -11,14 +11,14 @@ import TextBox from "components/TextBox";
import useAutoFocus from "hooks/useAutoFocus";
import { Category, Link } from "types";
import {
BuildCategory,
BuildLink,
HandleAxiosError,
IsValidURL,
} from "utils/front";
import prisma from "utils/prisma";
import { HandleAxiosError, IsValidURL } from "utils/front";
import { getSessionOrThrow } from "utils/session";
import getUserCategories from "lib/category/getUserCategories";
import getUserLink from "lib/link/getUserLink";
import getUserOrThrow from "lib/user/getUserOrThrow";
import PATHS from "constants/paths";
import styles from "styles/create.module.scss";
function EditLink({
@@ -73,8 +73,8 @@ function EditLink({
try {
const payload = { name, url, favorite, categoryId };
const { data } = await axios.put(`/api/link/edit/${link.id}`, payload);
router.push(`/?categoryId=${data?.categoryId}`);
const { data } = await axios.put(`${PATHS.API.LINK}/${link.id}`, payload);
router.push(`${PATHS.HOME}?categoryId=${data?.categoryId}`);
setSubmitted(true);
} catch (error) {
setError(HandleAxiosError(error));
@@ -133,31 +133,22 @@ function EditLink({
EditLink.authRequired = true;
export default EditLink;
export async function getServerSideProps({ query }) {
export async function getServerSideProps({ req, res, query }) {
const { lid } = query;
const categoriesDB = await prisma.category.findMany();
const categories = categoriesDB.map((categoryDB) =>
BuildCategory(categoryDB)
);
const session = await getSessionOrThrow(req, res);
const user = await getUserOrThrow(session);
const categories = await getUserCategories(user);
const linkDB = await prisma.link.findFirst({
where: { id: Number(lid) },
include: { category: true },
});
if (!linkDB) {
const link = await getUserLink(user, Number(lid));
if (!link) {
return {
redirect: {
destination: "/",
destination: PATHS.HOME,
},
};
}
const link = BuildLink(linkDB, {
categoryId: linkDB.categoryId,
categoryName: linkDB.category.name,
});
return {
props: {
link: JSON.parse(JSON.stringify(link)),

View File

@@ -8,9 +8,12 @@ import FormLayout from "components/FormLayout";
import PageTransition from "components/PageTransition";
import TextBox from "components/TextBox";
import PATHS from "constants/paths";
import getUserLink from "lib/link/getUserLink";
import getUser from "lib/user/getUser";
import { Link } from "types";
import { BuildLink, HandleAxiosError } from "utils/front";
import prisma from "utils/prisma";
import { HandleAxiosError } from "utils/front";
import { getSession } from "utils/session";
import styles from "styles/create.module.scss";
@@ -32,8 +35,8 @@ function RemoveLink({ link }: { link: Link }) {
nProgress.start();
try {
const { data } = await axios.delete(`/api/link/remove/${link.id}`);
router.push(`/?categoryId=${data?.categoryId}`);
const { data } = await axios.delete(`${PATHS.API.LINK}/${link.id}`);
router.push(`${PATHS.HOME}?categoryId=${data?.categoryId}`);
setSubmitted(true);
} catch (error) {
setError(HandleAxiosError(error));
@@ -94,25 +97,21 @@ function RemoveLink({ link }: { link: Link }) {
RemoveLink.authRequired = true;
export default RemoveLink;
export async function getServerSideProps({ query }) {
export async function getServerSideProps({ req, res, query }) {
const { lid } = query;
const linkDB = await prisma.link.findFirst({
where: { id: Number(lid) },
include: { category: true },
});
if (!linkDB) {
const session = await getSession(req, res);
const user = await getUser(session);
const link = await getUserLink(user, Number(lid));
if (!link) {
return {
redirect: {
destination: "/",
destination: PATHS.HOME,
},
};
}
const link = BuildLink(linkDB, {
categoryId: linkDB.categoryId,
categoryName: linkDB.category.name,
});
return {
props: {
link: JSON.parse(JSON.stringify(link)),

View File

@@ -1,10 +1,11 @@
import { Provider } from "next-auth/providers";
import { getProviders, getSession, signIn } from "next-auth/react";
import { getProviders, signIn } from "next-auth/react";
import { NextSeo } from "next-seo";
import Link from "next/link";
import MessageManager from "components/MessageManager/MessageManager";
import PATHS from "constants/paths";
import { getSession } from "utils/session";
import styles from "styles/login.module.scss";
@@ -36,8 +37,8 @@ export default function SignIn({ providers }: SignInProps) {
);
}
export async function getServerSideProps(context) {
const session = await getSession(context);
export async function getServerSideProps({ req, res }) {
const session = await getSession(req, res);
if (session) {
return {
redirect: {

10
src/types.d.ts vendored
View File

@@ -1,9 +1,14 @@
import { User } from "@prisma/client";
// TODO: extend @prisma/client type with Link[] instead of
// recreate interface (same for Link)
export interface Category {
id: number;
name: string;
links: Link[];
nextCategoryId: number;
authorId: User["id"];
author: User;
createdAt: Date;
updatedAt: Date;
@@ -20,7 +25,8 @@ export interface Link {
name: string;
};
nextLinkId: number;
authorId: User["id"];
author: User;
favorite: boolean;
createdAt: Date;

View File

@@ -1,50 +1,8 @@
import axios from "axios";
import { Category, Link } from "types";
export function BuildCategory({
id,
name,
nextCategoryId,
links = [],
createdAt,
updatedAt,
}): Category {
return {
id,
name,
links: links.map((link) =>
BuildLink(link, { categoryId: id, categoryName: name })
),
nextCategoryId,
createdAt,
updatedAt,
};
}
export function BuildLink(
{ id, name, url, nextLinkId, favorite, createdAt, updatedAt },
{ categoryId, categoryName }
): Link {
return {
id,
name,
url,
category: {
id: categoryId,
name: categoryName,
},
nextLinkId,
favorite,
createdAt,
updatedAt,
};
}
import { VALID_URL_REGEX } from "constants/url";
export function IsValidURL(url: string): boolean {
const regex = new RegExp(
/^(?:http(s)?:\/\/)[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.\%]+$/
);
const regex = new RegExp(VALID_URL_REGEX);
return url.match(regex) ? true : false;
}

21
src/utils/session.ts Normal file
View File

@@ -0,0 +1,21 @@
import { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth/next";
import { authOptions } from "pages/api/auth/[...nextauth]";
export async function getSession(req: NextApiRequest, res: NextApiResponse) {
return await getServerSession(req, res, authOptions);
}
export async function getSessionOrThrow(
req: NextApiRequest,
res: NextApiResponse
) {
const session = await getSession(req, res);
if (!session) {
throw new Error("You must be connected");
}
return session;
}