From 0d5973dfbb074dc83fb6f6f76f765f302e88bb7e Mon Sep 17 00:00:00 2001 From: Tim Ittermann Date: Sat, 3 Aug 2024 18:19:46 +0200 Subject: [PATCH 01/16] Add expiration time --- .../migration.sql | 8 ++++++++ prisma/schema.prisma | 1 + 2 files changed, 9 insertions(+) create mode 100644 prisma/migrations/20240803161929_add_expiration_to_invitation/migration.sql diff --git a/prisma/migrations/20240803161929_add_expiration_to_invitation/migration.sql b/prisma/migrations/20240803161929_add_expiration_to_invitation/migration.sql new file mode 100644 index 0000000..7782262 --- /dev/null +++ b/prisma/migrations/20240803161929_add_expiration_to_invitation/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `expiresAt` to the `MealPlanInvite` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE `MealPlanInvite` ADD COLUMN `expiresAt` DATETIME(3) NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e288737..fc182d7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -44,6 +44,7 @@ model MealPlanInvite { mealPlanId String @db.Char(25) createdAt DateTime @default(now()) createdByUserId String? + expiresAt DateTime user User? @relation(fields: [createdByUserId], references: [id], onDelete: SetNull) } From 72567492cf11d0dd36c05930f0b38da44f193d43 Mon Sep 17 00:00:00 2001 From: Tim Ittermann Date: Sat, 3 Aug 2024 18:24:48 +0200 Subject: [PATCH 02/16] feat: Update prisma client --- package.json | 2 +- pnpm-lock.yaml | 25 +++++++++---------------- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 591c622..1c2d22b 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "@fortawesome/fontawesome-svg-core": "^6.6.0", "@fortawesome/free-solid-svg-icons": "^6.6.0", "@fortawesome/react-fontawesome": "^0.2.2", - "@prisma/client": "^4.16.2", + "@prisma/client": "^5.17.0", "@types/luxon": "^3.4.2", "@types/randomstring": "^1.3.0", "classnames": "^2.5.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1469c15..03f5dbc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: dependencies: '@auth/prisma-adapter': specifier: ^2.4.1 - version: 2.4.1(@prisma/client@4.16.2(prisma@5.17.0)) + version: 2.4.1(@prisma/client@5.17.0(prisma@5.17.0)) '@fortawesome/fontawesome-svg-core': specifier: ^6.6.0 version: 6.6.0 @@ -21,8 +21,8 @@ importers: specifier: ^0.2.2 version: 0.2.2(@fortawesome/fontawesome-svg-core@6.6.0)(react@18.3.1) '@prisma/client': - specifier: ^4.16.2 - version: 4.16.2(prisma@5.17.0) + specifier: ^5.17.0 + version: 5.17.0(prisma@5.17.0) '@types/luxon': specifier: ^3.4.2 version: 3.4.2 @@ -298,9 +298,9 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@prisma/client@4.16.2': - resolution: {integrity: sha512-qCoEyxv1ZrQ4bKy39GnylE8Zq31IRmm8bNhNbZx7bF2cU5aiCCnSa93J2imF88MBjn7J9eUQneNxUQVJdl/rPQ==} - engines: {node: '>=14.17'} + '@prisma/client@5.17.0': + resolution: {integrity: sha512-N2tnyKayT0Zf7mHjwEyE8iG7FwTmXDHFZ1GnNhQp0pJUObsuel4ZZ1XwfuAYkq5mRIiC/Kot0kt0tGCfLJ70Jw==} + engines: {node: '>=16.13'} peerDependencies: prisma: '*' peerDependenciesMeta: @@ -310,9 +310,6 @@ packages: '@prisma/debug@5.17.0': resolution: {integrity: sha512-l7+AteR3P8FXiYyo496zkuoiJ5r9jLQEdUuxIxNCN1ud8rdbH3GTxm+f+dCyaSv9l9WY+29L9czaVRXz9mULfg==} - '@prisma/engines-version@4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81': - resolution: {integrity: sha512-q617EUWfRIDTriWADZ4YiWRZXCa/WuhNgLTVd+HqWLffjMSPzyM5uOWoauX91wvQClSKZU4pzI4JJLQ9Kl62Qg==} - '@prisma/engines-version@5.17.0-31.393aa359c9ad4a4bb28630fb5613f9c281cde053': resolution: {integrity: sha512-tUuxZZysZDcrk5oaNOdrBnnkoTtmNQPkzINFDjz7eG6vcs9AVDmA/F6K5Plsb2aQc/l5M2EnFqn3htng9FA4hg==} @@ -1896,10 +1893,10 @@ snapshots: preact: 10.11.3 preact-render-to-string: 5.2.3(preact@10.11.3) - '@auth/prisma-adapter@2.4.1(@prisma/client@4.16.2(prisma@5.17.0))': + '@auth/prisma-adapter@2.4.1(@prisma/client@5.17.0(prisma@5.17.0))': dependencies: '@auth/core': 0.34.1 - '@prisma/client': 4.16.2(prisma@5.17.0) + '@prisma/client': 5.17.0(prisma@5.17.0) transitivePeerDependencies: - '@simplewebauthn/browser' - '@simplewebauthn/server' @@ -2032,16 +2029,12 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@prisma/client@4.16.2(prisma@5.17.0)': - dependencies: - '@prisma/engines-version': 4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81 + '@prisma/client@5.17.0(prisma@5.17.0)': optionalDependencies: prisma: 5.17.0 '@prisma/debug@5.17.0': {} - '@prisma/engines-version@4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81': {} - '@prisma/engines-version@5.17.0-31.393aa359c9ad4a4bb28630fb5613f9c281cde053': {} '@prisma/engines@5.17.0': From c2e5898466a94a581997c11b29d5d0b58d034654 Mon Sep 17 00:00:00 2001 From: Tim Ittermann Date: Sat, 3 Aug 2024 18:24:58 +0200 Subject: [PATCH 03/16] feat: fix invitation link creation --- src/dal/mealPlans/createMealPlanInvitation.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/dal/mealPlans/createMealPlanInvitation.ts b/src/dal/mealPlans/createMealPlanInvitation.ts index 9459008..5207b53 100644 --- a/src/dal/mealPlans/createMealPlanInvitation.ts +++ b/src/dal/mealPlans/createMealPlanInvitation.ts @@ -1,4 +1,6 @@ +import { env } from "@/env/server.mjs"; import { prisma } from "@/server/db/client"; +import { DateTime, Duration } from "luxon"; import { generate } from "randomstring"; type Props = { @@ -7,13 +9,17 @@ type Props = { }; const stringLength = 12; +const invitationExpiresIn = Duration.fromISO(env.INVITATION_VALIDITY); /** Creates a invitation to a MealList */ export function createMealPlanInvitation({ mealPlanId, userId }: Props) { + const expiration = DateTime.now().plus(invitationExpiresIn); + return prisma.mealPlanInvite.create({ data: { createdByUserId: userId, mealPlanId: mealPlanId, + expiresAt: expiration.toJSDate(), invitationCode: generate({ length: stringLength, capitalization: "uppercase", From 705128594bc82865724dde30d95155f7c9e6345b Mon Sep 17 00:00:00 2001 From: Tim Ittermann Date: Sat, 3 Aug 2024 18:19:46 +0200 Subject: [PATCH 04/16] Add expiration time --- .../migration.sql | 8 ++++++++ prisma/schema.prisma | 1 + 2 files changed, 9 insertions(+) create mode 100644 prisma/migrations/20240803161929_add_expiration_to_invitation/migration.sql diff --git a/prisma/migrations/20240803161929_add_expiration_to_invitation/migration.sql b/prisma/migrations/20240803161929_add_expiration_to_invitation/migration.sql new file mode 100644 index 0000000..7782262 --- /dev/null +++ b/prisma/migrations/20240803161929_add_expiration_to_invitation/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `expiresAt` to the `MealPlanInvite` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE `MealPlanInvite` ADD COLUMN `expiresAt` DATETIME(3) NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e288737..fc182d7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -44,6 +44,7 @@ model MealPlanInvite { mealPlanId String @db.Char(25) createdAt DateTime @default(now()) createdByUserId String? + expiresAt DateTime user User? @relation(fields: [createdByUserId], references: [id], onDelete: SetNull) } From 42ac6769ca39d322c7c7e26ddc408e8f89ff149c Mon Sep 17 00:00:00 2001 From: Tim Ittermann Date: Sat, 3 Aug 2024 18:24:48 +0200 Subject: [PATCH 05/16] feat: Update prisma client --- package.json | 2 +- pnpm-lock.yaml | 25 +++++++++---------------- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 591c622..1c2d22b 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "@fortawesome/fontawesome-svg-core": "^6.6.0", "@fortawesome/free-solid-svg-icons": "^6.6.0", "@fortawesome/react-fontawesome": "^0.2.2", - "@prisma/client": "^4.16.2", + "@prisma/client": "^5.17.0", "@types/luxon": "^3.4.2", "@types/randomstring": "^1.3.0", "classnames": "^2.5.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1469c15..03f5dbc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: dependencies: '@auth/prisma-adapter': specifier: ^2.4.1 - version: 2.4.1(@prisma/client@4.16.2(prisma@5.17.0)) + version: 2.4.1(@prisma/client@5.17.0(prisma@5.17.0)) '@fortawesome/fontawesome-svg-core': specifier: ^6.6.0 version: 6.6.0 @@ -21,8 +21,8 @@ importers: specifier: ^0.2.2 version: 0.2.2(@fortawesome/fontawesome-svg-core@6.6.0)(react@18.3.1) '@prisma/client': - specifier: ^4.16.2 - version: 4.16.2(prisma@5.17.0) + specifier: ^5.17.0 + version: 5.17.0(prisma@5.17.0) '@types/luxon': specifier: ^3.4.2 version: 3.4.2 @@ -298,9 +298,9 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@prisma/client@4.16.2': - resolution: {integrity: sha512-qCoEyxv1ZrQ4bKy39GnylE8Zq31IRmm8bNhNbZx7bF2cU5aiCCnSa93J2imF88MBjn7J9eUQneNxUQVJdl/rPQ==} - engines: {node: '>=14.17'} + '@prisma/client@5.17.0': + resolution: {integrity: sha512-N2tnyKayT0Zf7mHjwEyE8iG7FwTmXDHFZ1GnNhQp0pJUObsuel4ZZ1XwfuAYkq5mRIiC/Kot0kt0tGCfLJ70Jw==} + engines: {node: '>=16.13'} peerDependencies: prisma: '*' peerDependenciesMeta: @@ -310,9 +310,6 @@ packages: '@prisma/debug@5.17.0': resolution: {integrity: sha512-l7+AteR3P8FXiYyo496zkuoiJ5r9jLQEdUuxIxNCN1ud8rdbH3GTxm+f+dCyaSv9l9WY+29L9czaVRXz9mULfg==} - '@prisma/engines-version@4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81': - resolution: {integrity: sha512-q617EUWfRIDTriWADZ4YiWRZXCa/WuhNgLTVd+HqWLffjMSPzyM5uOWoauX91wvQClSKZU4pzI4JJLQ9Kl62Qg==} - '@prisma/engines-version@5.17.0-31.393aa359c9ad4a4bb28630fb5613f9c281cde053': resolution: {integrity: sha512-tUuxZZysZDcrk5oaNOdrBnnkoTtmNQPkzINFDjz7eG6vcs9AVDmA/F6K5Plsb2aQc/l5M2EnFqn3htng9FA4hg==} @@ -1896,10 +1893,10 @@ snapshots: preact: 10.11.3 preact-render-to-string: 5.2.3(preact@10.11.3) - '@auth/prisma-adapter@2.4.1(@prisma/client@4.16.2(prisma@5.17.0))': + '@auth/prisma-adapter@2.4.1(@prisma/client@5.17.0(prisma@5.17.0))': dependencies: '@auth/core': 0.34.1 - '@prisma/client': 4.16.2(prisma@5.17.0) + '@prisma/client': 5.17.0(prisma@5.17.0) transitivePeerDependencies: - '@simplewebauthn/browser' - '@simplewebauthn/server' @@ -2032,16 +2029,12 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@prisma/client@4.16.2(prisma@5.17.0)': - dependencies: - '@prisma/engines-version': 4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81 + '@prisma/client@5.17.0(prisma@5.17.0)': optionalDependencies: prisma: 5.17.0 '@prisma/debug@5.17.0': {} - '@prisma/engines-version@4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81': {} - '@prisma/engines-version@5.17.0-31.393aa359c9ad4a4bb28630fb5613f9c281cde053': {} '@prisma/engines@5.17.0': From 736c0e48e2b473eccd5c588465a48df4602f5d12 Mon Sep 17 00:00:00 2001 From: Tim Ittermann Date: Sat, 3 Aug 2024 18:24:58 +0200 Subject: [PATCH 06/16] feat: fix invitation link creation --- src/dal/mealPlans/createMealPlanInvitation.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/dal/mealPlans/createMealPlanInvitation.ts b/src/dal/mealPlans/createMealPlanInvitation.ts index 9459008..5207b53 100644 --- a/src/dal/mealPlans/createMealPlanInvitation.ts +++ b/src/dal/mealPlans/createMealPlanInvitation.ts @@ -1,4 +1,6 @@ +import { env } from "@/env/server.mjs"; import { prisma } from "@/server/db/client"; +import { DateTime, Duration } from "luxon"; import { generate } from "randomstring"; type Props = { @@ -7,13 +9,17 @@ type Props = { }; const stringLength = 12; +const invitationExpiresIn = Duration.fromISO(env.INVITATION_VALIDITY); /** Creates a invitation to a MealList */ export function createMealPlanInvitation({ mealPlanId, userId }: Props) { + const expiration = DateTime.now().plus(invitationExpiresIn); + return prisma.mealPlanInvite.create({ data: { createdByUserId: userId, mealPlanId: mealPlanId, + expiresAt: expiration.toJSDate(), invitationCode: generate({ length: stringLength, capitalization: "uppercase", From 7cb0a80e795e566b452f19b931e279f0510b0fc1 Mon Sep 17 00:00:00 2001 From: Tim Ittermann Date: Sat, 3 Aug 2024 22:32:01 +0200 Subject: [PATCH 07/16] chore: add fa brands icons --- package.json | 1 + pnpm-lock.yaml | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/package.json b/package.json index 1c2d22b..8d042f6 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "@auth/prisma-adapter": "^2.4.1", "@fortawesome/fontawesome-svg-core": "^6.6.0", + "@fortawesome/free-brands-svg-icons": "^6.6.0", "@fortawesome/free-solid-svg-icons": "^6.6.0", "@fortawesome/react-fontawesome": "^0.2.2", "@prisma/client": "^5.17.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03f5dbc..65af90a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@fortawesome/fontawesome-svg-core': specifier: ^6.6.0 version: 6.6.0 + '@fortawesome/free-brands-svg-icons': + specifier: ^6.6.0 + version: 6.6.0 '@fortawesome/free-solid-svg-icons': specifier: ^6.6.0 version: 6.6.0 @@ -174,6 +177,10 @@ packages: resolution: {integrity: sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg==} engines: {node: '>=6'} + '@fortawesome/free-brands-svg-icons@6.6.0': + resolution: {integrity: sha512-1MPD8lMNW/earme4OQi1IFHtmHUwAKgghXlNwWi9GO7QkTfD+IIaYpIai4m2YJEzqfEji3jFHX1DZI5pbY/biQ==} + engines: {node: '>=6'} + '@fortawesome/free-solid-svg-icons@6.6.0': resolution: {integrity: sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA==} engines: {node: '>=6'} @@ -1931,6 +1938,10 @@ snapshots: dependencies: '@fortawesome/fontawesome-common-types': 6.6.0 + '@fortawesome/free-brands-svg-icons@6.6.0': + dependencies: + '@fortawesome/fontawesome-common-types': 6.6.0 + '@fortawesome/free-solid-svg-icons@6.6.0': dependencies: '@fortawesome/fontawesome-common-types': 6.6.0 From 06f52e8b7700670c065bce57484a82a214b01952 Mon Sep 17 00:00:00 2001 From: Tim Ittermann Date: Sat, 3 Aug 2024 22:32:11 +0200 Subject: [PATCH 08/16] Create sharing invitation links --- .../mealPlan/invite/[mealPlanId]/page.tsx | 47 ++++++++++++++- src/components/common/SocialShareLinks.tsx | 60 +++++++++++++++++++ src/dal/mealPlans/createMealPlanInvitation.ts | 34 ++++++++--- src/env/schema.mjs | 1 + src/functions/buildUrl.ts | 40 +++++++++++++ src/locales/de.ts | 10 ++++ src/locales/en.ts | 10 ++++ 7 files changed, 192 insertions(+), 10 deletions(-) create mode 100644 src/components/common/SocialShareLinks.tsx create mode 100644 src/functions/buildUrl.ts diff --git a/src/app/[locale]/(userArea)/mealPlan/invite/[mealPlanId]/page.tsx b/src/app/[locale]/(userArea)/mealPlan/invite/[mealPlanId]/page.tsx index f336f0f..d220116 100644 --- a/src/app/[locale]/(userArea)/mealPlan/invite/[mealPlanId]/page.tsx +++ b/src/app/[locale]/(userArea)/mealPlan/invite/[mealPlanId]/page.tsx @@ -1,3 +1,46 @@ -export default function InvitePage() { - return
InvitePage
; +import { SocialShareLinks } from "@/components/common/SocialShareLinks"; +import { createMealPlanInvitation } from "@/dal/mealPlans/createMealPlanInvitation"; +import { getMealPlan } from "@/dal/mealPlans/getMealPlan"; +import { buildUrl } from "@/functions/buildUrl"; +import { getUserId } from "@/functions/user/getUserId"; +import { getCurrentLocale, getScopedI18n } from "@/locales/server"; +import { notFound } from "next/navigation"; + +type Props = { + params: { + mealPlanId: string; + }; +}; + +export default async function InvitePage({ params }: Props) { + const userId = await getUserId(true); + const mealPlan = await getMealPlan(userId, params.mealPlanId); + if (mealPlan == null) notFound(); + + const t = await getScopedI18n("invite"); + const currentLocale = getCurrentLocale(); + + const invitation = await createMealPlanInvitation(params.mealPlanId, userId); + + const invitationLink = buildUrl({ + path: `/${currentLocale}/mealPlan/join/${invitation.invitationCode}`, + }); + + return ( +
+

{t("invite", mealPlan)}

+

{t("inviteMessage")}

+

{t("inviteHint")}

+
+ {invitationLink.toString()} +
+
+ +
+
+ ); } diff --git a/src/components/common/SocialShareLinks.tsx b/src/components/common/SocialShareLinks.tsx new file mode 100644 index 0000000..3ed494d --- /dev/null +++ b/src/components/common/SocialShareLinks.tsx @@ -0,0 +1,60 @@ +"use server"; + +import { getScopedI18n } from "@/locales/server"; +import { faTelegram, faWhatsapp } from "@fortawesome/free-brands-svg-icons"; +import type { IconDefinition } from "@fortawesome/free-solid-svg-icons"; +import { faMessage } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import Link from "next/link"; + +type Props = { + messagePayload: string; +}; + +type SocialProvider = { + icon: IconDefinition; + name: string; + openLink: (messagePayload: string) => string; +}; + +const SocialProviders: SocialProvider[] = [ + { + icon: faWhatsapp, + name: "WhatsApp", + openLink: (messagePayload) => + `https://wa.me/?text=${encodeURIComponent(messagePayload)}`, + }, + { + icon: faMessage, + name: "SMS / iMessage", + openLink: (messagePayload) => + `sms:&body=${encodeURIComponent(messagePayload)}`, + }, + { + icon: faTelegram, + name: "Telegram", + openLink: (messagePayload) => + `https://t.me/share/url?url=${encodeURIComponent(messagePayload)}`, + }, +]; + +export const SocialShareLinks = async ({ messagePayload }: Props) => { + const t = await getScopedI18n("invite"); + + return ( +
+ {SocialProviders.map((p) => ( + + + {t("shareVia", p)} + + ))} +
+ ); +}; diff --git a/src/dal/mealPlans/createMealPlanInvitation.ts b/src/dal/mealPlans/createMealPlanInvitation.ts index 5207b53..e017b53 100644 --- a/src/dal/mealPlans/createMealPlanInvitation.ts +++ b/src/dal/mealPlans/createMealPlanInvitation.ts @@ -3,19 +3,37 @@ import { prisma } from "@/server/db/client"; import { DateTime, Duration } from "luxon"; import { generate } from "randomstring"; -type Props = { - userId: string; - mealPlanId: string; -}; - const stringLength = 12; const invitationExpiresIn = Duration.fromISO(env.INVITATION_VALIDITY); -/** Creates a invitation to a MealList */ -export function createMealPlanInvitation({ mealPlanId, userId }: Props) { +/** + * Creates an Invitation (or return a valid one if it exists) for a Meal Plan + * @param mealPlanId Meal Plan Id + * @param userId User Id + * @returns A invitation code + */ +export async function createMealPlanInvitation( + mealPlanId: string, + userId: string +) { + const now = new Date(); + + // Check for existing invitation + const existingInvitation = await prisma.mealPlanInvite.findFirst({ + where: { + mealPlanId: mealPlanId, + createdByUserId: userId, + expiresAt: { + gt: now, + }, + }, + }); + + if (existingInvitation) return existingInvitation; + const expiration = DateTime.now().plus(invitationExpiresIn); - return prisma.mealPlanInvite.create({ + return await prisma.mealPlanInvite.create({ data: { createdByUserId: userId, mealPlanId: mealPlanId, diff --git a/src/env/schema.mjs b/src/env/schema.mjs index 41e6492..26d117b 100644 --- a/src/env/schema.mjs +++ b/src/env/schema.mjs @@ -13,6 +13,7 @@ export const serverSchema = z.object({ INVITATION_VALIDITY: z.string().default("P30D"), SESSION_VALIDITY_IN_SECONDS: z.number().default(60 * 60 * 24 * 30), // 30 days NEXTAUTH_SECRET: z.string(), + ROOT_URL: z.string().url().optional(), }); /** diff --git a/src/functions/buildUrl.ts b/src/functions/buildUrl.ts new file mode 100644 index 0000000..e9c77e4 --- /dev/null +++ b/src/functions/buildUrl.ts @@ -0,0 +1,40 @@ +import { env } from "@/env/server.mjs"; +import { headers } from "next/headers"; + +function getHostFromHeaders() { + const currentHeaders = headers(); + + let host = + currentHeaders.get("x-forwarded-for") ?? currentHeaders.get("host"); + + if (host != null) { + if (host.includes(":")) host = `[${host}]`; + const protocol = currentHeaders.get("X-Forwarded-Proto") ?? "https"; + return `${protocol}://${host}`; + } +} + +type Props = { + path: string; + search?: string | URLSearchParams; +}; + +/** + * Utility to build an URL using the host env or the current request as host + * @param props Properties + * @returns URL + */ +export function buildUrl({ path, search }: Props) { + const host = env.ROOT_URL ?? getHostFromHeaders(); + if (host === undefined) { + throw new Error("Could not determine host"); + } + + const url = new URL(path, host); + url.pathname = path; + + if (search != undefined) { + url.search = typeof search === "string" ? search : search.toString(); + } + return url; +} diff --git a/src/locales/de.ts b/src/locales/de.ts index 6339158..9dd8958 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -29,4 +29,14 @@ export default { confirm: "Bestätigen", cancel: "Abbrechen", }, + invite: { + title: "Andere einladen", + invite: "Lade andere zu deinem Essensplan {title} ein", + inviteMessage: "Lade andere ein, um gemeinsam Mahlzeiten zu planen.", + inviteHint: + "Sende den folgenden Link an deine Freunde, um diese einzuladen.", + shareVia: "Teilen über {name}", + shareText: + "Hey, möchtest du an meinem Essensplan teilnehmen? Dann klicke auf den folgenden Link: {invitationLink}", + }, } as const; diff --git a/src/locales/en.ts b/src/locales/en.ts index 23b3426..c11267d 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -29,4 +29,14 @@ export default { confirm: "Confirm", cancel: "Cancel", }, + invite: { + title: "Invite others", + invite: "Invite to your Meal Plan: {title}", + inviteMessage: + "Invite others to join your meal plan and plan meals together.", + inviteHint: "Send the following link to invite others.", + shareVia: "Share via {name}", + shareText: + "Hey, do you want to join my meal plan? Then click on the following link: {invitationLink}", + }, } as const; From 2d54dce09deba162ee338cd8784464f71854f69a Mon Sep 17 00:00:00 2001 From: Tim Ittermann Date: Sun, 4 Aug 2024 11:27:55 +0200 Subject: [PATCH 09/16] fix: build error with not found page --- src/app/not-found.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index ffd5b43..a4c7f9e 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -1,6 +1,8 @@ -import Link from "next/link"; +import { setStaticParamsLocale } from "next-international/server"; export default function NotFound() { + setStaticParamsLocale("en"); + return (
Not found @@ -10,9 +12,9 @@ export default function NotFound() { 404 Not Found

Could not find requested resource.

- + Return Home - +
From 93f08bbbe4b939648e1384749e4cff9b15df5ef7 Mon Sep 17 00:00:00 2001 From: Tim Ittermann Date: Sun, 4 Aug 2024 11:46:45 +0200 Subject: [PATCH 10/16] fix: build with preserving locale --- src/app/not-found.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index a4c7f9e..062d801 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -1,7 +1,12 @@ import { setStaticParamsLocale } from "next-international/server"; +import { PHASE_PRODUCTION_BUILD } from "next/dist/shared/lib/constants"; export default function NotFound() { - setStaticParamsLocale("en"); + // This guard should protect, that the locale is set only in production build + // without the guard, it will always override the current locale + if (process.env.NEXT_PHASE === PHASE_PRODUCTION_BUILD) { + setStaticParamsLocale("en"); + } return (
From 96ca9b5088e279a303c7454c86b7f6587bd6f851 Mon Sep 17 00:00:00 2001 From: Tim Ittermann Date: Sun, 4 Aug 2024 12:31:52 +0200 Subject: [PATCH 11/16] Correct relations in Schema --- prisma/schema.prisma | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fc182d7..703f21d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -27,6 +27,7 @@ model MealPlan { title String @db.Text mealPlanAssignments MealPlanAssignment[] mealEntries MealEntry[] + invitations MealPlanInvite[] } model MealPlanAssignment { @@ -43,10 +44,11 @@ model MealPlanInvite { invitationCode String @id @db.Char(12) mealPlanId String @db.Char(25) createdAt DateTime @default(now()) - createdByUserId String? + createdByUserId String expiresAt DateTime - user User? @relation(fields: [createdByUserId], references: [id], onDelete: SetNull) + user User? @relation(fields: [createdByUserId], references: [id], onDelete: Cascade) + mealPlan MealPlan @relation(fields: [mealPlanId], references: [id], onDelete: Cascade) } model User { From 6ece2c9a3a5aead509cc3c22437e3fcccb564b9d Mon Sep 17 00:00:00 2001 From: Tim Ittermann Date: Sun, 4 Aug 2024 12:32:38 +0200 Subject: [PATCH 12/16] feat: Add relations migration --- .../migration.sql | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 prisma/migrations/20240804103216_add_relation_for_invitation/migration.sql diff --git a/prisma/migrations/20240804103216_add_relation_for_invitation/migration.sql b/prisma/migrations/20240804103216_add_relation_for_invitation/migration.sql new file mode 100644 index 0000000..dbf2615 --- /dev/null +++ b/prisma/migrations/20240804103216_add_relation_for_invitation/migration.sql @@ -0,0 +1,17 @@ +/* + Warnings: + + - Made the column `createdByUserId` on table `MealPlanInvite` required. This step will fail if there are existing NULL values in that column. + +*/ +-- DropForeignKey +ALTER TABLE `MealPlanInvite` DROP FOREIGN KEY `MealPlanInvite_createdByUserId_fkey`; + +-- AlterTable +ALTER TABLE `MealPlanInvite` MODIFY `createdByUserId` VARCHAR(191) NOT NULL; + +-- AddForeignKey +ALTER TABLE `MealPlanInvite` ADD CONSTRAINT `MealPlanInvite_createdByUserId_fkey` FOREIGN KEY (`createdByUserId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `MealPlanInvite` ADD CONSTRAINT `MealPlanInvite_mealPlanId_fkey` FOREIGN KEY (`mealPlanId`) REFERENCES `MealPlan`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; From 5e04d19585b2bb883d3132dce873478fbf8f94c0 Mon Sep 17 00:00:00 2001 From: Tim Ittermann Date: Sun, 4 Aug 2024 12:32:50 +0200 Subject: [PATCH 13/16] feat: Build first invitation joining step --- src/app/[locale]/(landing)/page.tsx | 47 ++++++++++++++- .../mealPlan/invite/[mealPlanId]/page.tsx | 8 ++- .../mealPlan/join/[invitationCode]/page.tsx | 9 +++ .../invitation/InvitationHeader.tsx | 59 +++++++++++++++++++ src/dal/user/getInvitation.ts | 44 ++++++++++++++ src/dal/user/redeemMealPlanInvitation.ts | 48 +++------------ src/functions/buildUrl.ts | 6 +- src/functions/user/redirectWithLocale.ts | 2 +- src/locales/de.ts | 10 ++++ src/locales/en.ts | 10 ++++ 10 files changed, 196 insertions(+), 47 deletions(-) create mode 100644 src/app/[locale]/(userArea)/mealPlan/join/[invitationCode]/page.tsx create mode 100644 src/components/invitation/InvitationHeader.tsx create mode 100644 src/dal/user/getInvitation.ts diff --git a/src/app/[locale]/(landing)/page.tsx b/src/app/[locale]/(landing)/page.tsx index f5c9a20..500b3ee 100644 --- a/src/app/[locale]/(landing)/page.tsx +++ b/src/app/[locale]/(landing)/page.tsx @@ -1,13 +1,42 @@ import { auth } from "@/auth"; +import { InvitationHeader } from "@/components/invitation/InvitationHeader"; +import { getInvitation } from "@/dal/user/getInvitation"; import { redirectWithLocale } from "@/functions/user/redirectWithLocale"; import { getScopedI18n } from "@/locales/server"; import { SignInButtons } from "./SignInButtons"; -export default async function LandingPage() { +type Props = { + searchParams: { + invitationCode?: string; + }; +}; + +async function handleInvitation( + invitationCode: string | undefined, + isSignedIn: boolean +) { + if (invitationCode === undefined) return undefined; + + const invitation = await getInvitation(invitationCode); + + if (isSignedIn && invitation != null && invitation != "EXPIRED") { + // Redirect to join page + redirectWithLocale(`/mealPlan/join/${invitationCode}`); + } + + return invitation; +} + +export default async function LandingPage({ searchParams }: Props) { const t = await getScopedI18n("landing"); const currentUser = await auth(); - if (currentUser != null) redirectWithLocale(`/mealPlan`); + const invitation = await handleInvitation( + searchParams.invitationCode, + currentUser != null + ); + if (currentUser != null && invitation == null) + redirectWithLocale(`/mealPlan`); return (
@@ -23,6 +52,11 @@ export default async function LandingPage() {

{t("subtitle")}

+ + {invitation !== undefined && ( + + )} +
@@ -33,6 +67,15 @@ export default async function LandingPage() {

Feature Box

+ +
+
+

Search Params

+

+ {JSON.stringify(searchParams)} +

+
+
diff --git a/src/app/[locale]/(userArea)/mealPlan/invite/[mealPlanId]/page.tsx b/src/app/[locale]/(userArea)/mealPlan/invite/[mealPlanId]/page.tsx index d220116..7c71c06 100644 --- a/src/app/[locale]/(userArea)/mealPlan/invite/[mealPlanId]/page.tsx +++ b/src/app/[locale]/(userArea)/mealPlan/invite/[mealPlanId]/page.tsx @@ -3,7 +3,7 @@ import { createMealPlanInvitation } from "@/dal/mealPlans/createMealPlanInvitati import { getMealPlan } from "@/dal/mealPlans/getMealPlan"; import { buildUrl } from "@/functions/buildUrl"; import { getUserId } from "@/functions/user/getUserId"; -import { getCurrentLocale, getScopedI18n } from "@/locales/server"; +import { getScopedI18n } from "@/locales/server"; import { notFound } from "next/navigation"; type Props = { @@ -18,12 +18,14 @@ export default async function InvitePage({ params }: Props) { if (mealPlan == null) notFound(); const t = await getScopedI18n("invite"); - const currentLocale = getCurrentLocale(); const invitation = await createMealPlanInvitation(params.mealPlanId, userId); const invitationLink = buildUrl({ - path: `/${currentLocale}/mealPlan/join/${invitation.invitationCode}`, + path: "/", + search: { + invitationCode: invitation.invitationCode, + }, }); return ( diff --git a/src/app/[locale]/(userArea)/mealPlan/join/[invitationCode]/page.tsx b/src/app/[locale]/(userArea)/mealPlan/join/[invitationCode]/page.tsx new file mode 100644 index 0000000..9ad1660 --- /dev/null +++ b/src/app/[locale]/(userArea)/mealPlan/join/[invitationCode]/page.tsx @@ -0,0 +1,9 @@ +type Props = { + params: { + invitationCode: string; + }; +}; + +export default async function InvitationPage({ params }: Props) { + return
InvitationCode {params.invitationCode}
; +} diff --git a/src/components/invitation/InvitationHeader.tsx b/src/components/invitation/InvitationHeader.tsx new file mode 100644 index 0000000..c851906 --- /dev/null +++ b/src/components/invitation/InvitationHeader.tsx @@ -0,0 +1,59 @@ +import type { InvitationReturn } from "@/dal/user/getInvitation"; +import { getMealPlanLabel } from "@/functions/user/getMealPlanLabel"; +import { getI18n, getScopedI18n } from "@/locales/server"; +import { + faExclamationTriangle, + faInfoCircle, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +type Props = { + invitation: InvitationReturn; +}; + +export async function InvitationHeader({ invitation }: Props) { + const t = await getScopedI18n("invitation"); + + if (invitation == null) + return ( +
+ + {t("notFound")} +
+ ); + + if (invitation == "EXPIRED") + return ( +
+ + {t("expired")} +
+ ); + + return ( +
+ +
+

+ {t("loginToJoinTitle", { + name: invitation.user.name ?? t("unknownUser"), + mealPlanTitle: await getMealPlanLabel( + invitation.mealPlan, + await getI18n() + ), + })} +

+

{t("loginToJoinSubtitle")}

+
+
+ ); +} diff --git a/src/dal/user/getInvitation.ts b/src/dal/user/getInvitation.ts new file mode 100644 index 0000000..087d1ef --- /dev/null +++ b/src/dal/user/getInvitation.ts @@ -0,0 +1,44 @@ +import { prisma } from "@/server/db/client"; +import { MealPlan, MealPlanInvite, User } from "@prisma/client"; + +/** + * Returns if the current invitation is still valid + * @param invitation The invitation + * @returns Is currently valid + */ +const isCurrentlyValid = (invitation: MealPlanInvite) => { + const now = new Date(); + return invitation.expiresAt >= now; +}; + +export type InvitationReturn = + | null + | "EXPIRED" + | (MealPlanInvite & { + mealPlan: MealPlan; + user: User; + }); + +/** + * Gets an invitation by its code + * @param invitationCode Invitation code + * @returns The invitation, or null if not found, or "EXPIRED" if the invitation is expired + */ +export async function getInvitation( + invitationCode: string +): Promise { + const invitation = await prisma.mealPlanInvite.findUnique({ + where: { + invitationCode, + }, + include: { + mealPlan: true, + user: true, + }, + }); + + if (invitation === null) return null; + if (!isCurrentlyValid(invitation)) return "EXPIRED"; + + return invitation; +} diff --git a/src/dal/user/redeemMealPlanInvitation.ts b/src/dal/user/redeemMealPlanInvitation.ts index 372bbde..ba1fad6 100644 --- a/src/dal/user/redeemMealPlanInvitation.ts +++ b/src/dal/user/redeemMealPlanInvitation.ts @@ -1,55 +1,25 @@ -import type { MealPlanInvite, PrismaClient } from "@prisma/client"; -import { DateTime, Duration } from "luxon"; -import { env } from "../../env/server.mjs"; - -type Props = { - userId: string; - invitationCode: string; - client: PrismaClient; -}; - -/** - * Returns if the current invitation is still valid - * @param invitation The invitation - * @returns Is currently valid - */ -const isCurrentlyValid = (invitation: MealPlanInvite) => { - const now = DateTime.now(); - const validity = Duration.fromISO(env.INVITATION_VALIDITY!); - return DateTime.fromJSDate(invitation.createdAt).plus(validity) >= now; -}; +import { prisma } from "@/server/db/client"; +import type { MealPlanInvite } from "@prisma/client"; /** * Redeems a MealPlanInvitation - * @param param0 Props * @returns The joined MealPlan */ -export const redeemMealPlanInvitation = async ({ - userId, - invitationCode, - client, -}: Props) => { - const invitation = await client.mealPlanInvite.findUnique({ - where: { - invitationCode, - }, - }); - - if (invitation === null) throw new Error("INVITATION_NOT_FOUND"); - - if (!isCurrentlyValid(invitation)) throw new Error("INVITATION_EXPIRED"); - +export const redeemMealPlanInvitation = async ( + invitation: MealPlanInvite, + targetUserId: string +) => { // Assign - await client.mealPlanAssignment.create({ + await prisma.mealPlanAssignment.create({ data: { userDefault: false, mealPlanId: invitation.mealPlanId, - userId: userId, + userId: targetUserId, }, }); // Return MealPlan - return await client.mealPlan.findUniqueOrThrow({ + return await prisma.mealPlan.findUniqueOrThrow({ where: { id: invitation.mealPlanId, }, diff --git a/src/functions/buildUrl.ts b/src/functions/buildUrl.ts index e9c77e4..fcf8f0a 100644 --- a/src/functions/buildUrl.ts +++ b/src/functions/buildUrl.ts @@ -16,7 +16,7 @@ function getHostFromHeaders() { type Props = { path: string; - search?: string | URLSearchParams; + search?: Record; }; /** @@ -34,7 +34,9 @@ export function buildUrl({ path, search }: Props) { url.pathname = path; if (search != undefined) { - url.search = typeof search === "string" ? search : search.toString(); + for (const [key, value] of Object.entries(search)) { + url.searchParams.set(key, value); + } } return url; } diff --git a/src/functions/user/redirectWithLocale.ts b/src/functions/user/redirectWithLocale.ts index b830132..434b0f8 100644 --- a/src/functions/user/redirectWithLocale.ts +++ b/src/functions/user/redirectWithLocale.ts @@ -5,7 +5,7 @@ import { redirect } from "next/navigation"; * Redirects to the target with the current locale as prefix. * @example redirectWithLocale("/mealPlan") */ -export function redirectWithLocale(target: string) { +export function redirectWithLocale(target: string): never { redirect(getLinkWithLocale(target)); } diff --git a/src/locales/de.ts b/src/locales/de.ts index 9dd8958..4aba731 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -39,4 +39,14 @@ export default { shareText: "Hey, möchtest du an meinem Essensplan teilnehmen? Dann klicke auf den folgenden Link: {invitationLink}", }, + invitation: { + expired: "Diese Einladung ist abgelaufen", + notFound: + "Diese Einladung wurde nicht gefunden. Aber du kannst trotzdem einen eigenen Essensplan erstellen", + loginToJoinTitle: + "{name} hat dich eingeladen, dem Essensplan {mealPlanTitle} beizutreten", + loginToJoinSubtitle: + "Melde dich an, um diesem Essensplan beizutreten und gemeinsam Mahlzeiten zu planen", + unknownUser: "Ein Benutzer", + }, } as const; diff --git a/src/locales/en.ts b/src/locales/en.ts index c11267d..ba08e94 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -39,4 +39,14 @@ export default { shareText: "Hey, do you want to join my meal plan? Then click on the following link: {invitationLink}", }, + invitation: { + expired: "This invitation has expired", + notFound: + "This invitation was not found. But you can still create your own meal plan", + loginToJoinTitle: + "{name} invited you to join the meal plan {mealPlanTitle}", + loginToJoinSubtitle: + "Sign in to join this meal plan and plan meals together", + unknownUser: "A user", + }, } as const; From a8566fcaf4cd212021ec9d857749cc0c8c353244 Mon Sep 17 00:00:00 2001 From: Tim Ittermann Date: Sun, 4 Aug 2024 13:31:54 +0200 Subject: [PATCH 14/16] feat: Finalize plan joining --- src/actions/acceptInvitationAction.ts | 18 +++++ src/app/[locale]/(landing)/page.tsx | 2 +- .../mealPlan/invite/[mealPlanId]/page.tsx | 3 +- .../join/[invitationCode]/AcceptButton.tsx | 20 ++++++ .../mealPlan/join/[invitationCode]/page.tsx | 60 +++++++++++++++- src/components/common/Heading.tsx | 5 ++ src/components/common/NavBar.tsx | 2 +- src/components/common/ProfileImage.tsx | 14 ++-- .../invitation/InvitationHeader.tsx | 15 ++-- src/components/mealEntries/MealPlanEntry.tsx | 2 +- src/dal/user/getInvitation.ts | 72 +++++++++++++++---- src/dal/user/redeemMealPlanInvitation.ts | 8 --- src/functions/user/getUserId.ts | 7 +- src/locales/de.ts | 2 + src/locales/en.ts | 2 + 15 files changed, 195 insertions(+), 37 deletions(-) create mode 100644 src/actions/acceptInvitationAction.ts create mode 100644 src/app/[locale]/(userArea)/mealPlan/join/[invitationCode]/AcceptButton.tsx create mode 100644 src/components/common/Heading.tsx diff --git a/src/actions/acceptInvitationAction.ts b/src/actions/acceptInvitationAction.ts new file mode 100644 index 0000000..738ecb0 --- /dev/null +++ b/src/actions/acceptInvitationAction.ts @@ -0,0 +1,18 @@ +"use server"; + +import { getInvitation } from "@/dal/user/getInvitation"; +import { redeemMealPlanInvitation } from "@/dal/user/redeemMealPlanInvitation"; +import { getUserId } from "@/functions/user/getUserId"; +import { redirectWithLocale } from "@/functions/user/redirectWithLocale"; + +export async function acceptInvitationAction(invitationCode: string) { + const userId = await getUserId(null); + const invitation = await getInvitation(invitationCode, userId); + + if (invitation.result != "OK") { + throw new Error("Invitation not found"); + } + + await redeemMealPlanInvitation(invitation.invitation, userId); + redirectWithLocale(`/mealPlan/${invitation.invitation.mealPlan.id}`); +} diff --git a/src/app/[locale]/(landing)/page.tsx b/src/app/[locale]/(landing)/page.tsx index 500b3ee..ffb6eb7 100644 --- a/src/app/[locale]/(landing)/page.tsx +++ b/src/app/[locale]/(landing)/page.tsx @@ -19,7 +19,7 @@ async function handleInvitation( const invitation = await getInvitation(invitationCode); - if (isSignedIn && invitation != null && invitation != "EXPIRED") { + if (isSignedIn) { // Redirect to join page redirectWithLocale(`/mealPlan/join/${invitationCode}`); } diff --git a/src/app/[locale]/(userArea)/mealPlan/invite/[mealPlanId]/page.tsx b/src/app/[locale]/(userArea)/mealPlan/invite/[mealPlanId]/page.tsx index 7c71c06..750d367 100644 --- a/src/app/[locale]/(userArea)/mealPlan/invite/[mealPlanId]/page.tsx +++ b/src/app/[locale]/(userArea)/mealPlan/invite/[mealPlanId]/page.tsx @@ -1,3 +1,4 @@ +import { Heading } from "@/components/common/Heading"; import { SocialShareLinks } from "@/components/common/SocialShareLinks"; import { createMealPlanInvitation } from "@/dal/mealPlans/createMealPlanInvitation"; import { getMealPlan } from "@/dal/mealPlans/getMealPlan"; @@ -30,7 +31,7 @@ export default async function InvitePage({ params }: Props) { return (
-

{t("invite", mealPlan)}

+ {t("invite", mealPlan)}

{t("inviteMessage")}

{t("inviteHint")}

diff --git a/src/app/[locale]/(userArea)/mealPlan/join/[invitationCode]/AcceptButton.tsx b/src/app/[locale]/(userArea)/mealPlan/join/[invitationCode]/AcceptButton.tsx new file mode 100644 index 0000000..69b567c --- /dev/null +++ b/src/app/[locale]/(userArea)/mealPlan/join/[invitationCode]/AcceptButton.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { acceptInvitationAction } from "@/actions/acceptInvitationAction"; +import type { FC, PropsWithChildren } from "react"; + +type Props = { + invitationCode: string; +}; + +export const AcceptButton: FC> = ({ + invitationCode, + children, +}) => ( + +); diff --git a/src/app/[locale]/(userArea)/mealPlan/join/[invitationCode]/page.tsx b/src/app/[locale]/(userArea)/mealPlan/join/[invitationCode]/page.tsx index 9ad1660..3e7a5c3 100644 --- a/src/app/[locale]/(userArea)/mealPlan/join/[invitationCode]/page.tsx +++ b/src/app/[locale]/(userArea)/mealPlan/join/[invitationCode]/page.tsx @@ -1,3 +1,13 @@ +import { acceptInvitationAction } from "@/actions/acceptInvitationAction"; +import { ProfileImage } from "@/components/common/ProfileImage"; +import { InvitationHeader } from "@/components/invitation/InvitationHeader"; +import { getInvitation } from "@/dal/user/getInvitation"; +import { getMealPlanLabel } from "@/functions/user/getMealPlanLabel"; +import { getUserId } from "@/functions/user/getUserId"; +import { redirectWithLocale } from "@/functions/user/redirectWithLocale"; +import { getI18n, getScopedI18n } from "@/locales/server"; +import { AcceptButton } from "./AcceptButton"; + type Props = { params: { invitationCode: string; @@ -5,5 +15,53 @@ type Props = { }; export default async function InvitationPage({ params }: Props) { - return
InvitationCode {params.invitationCode}
; + const userId = await getUserId(true); + const invitation = await getInvitation(params.invitationCode, userId); + + // Redirect if joined + if (invitation.result === "JOINED") { + redirectWithLocale(`/mealPlan/${invitation.invitation.mealPlan.id}`); + } + + const t = await getScopedI18n("invitation"); + const mealPlanTitle = + invitation.result === "OK" + ? await getMealPlanLabel(invitation.invitation.mealPlan, await getI18n()) + : ""; + + const acceptAction = + invitation.result === "OK" + ? acceptInvitationAction.bind(null, invitation.invitation.invitationCode) + : undefined; + + return ( +
+ + {invitation.result === "OK" && ( +
+
+ +
+
+

{t("header")}

+

+ {t("loginToJoinTitle", { + mealPlanTitle, + name: invitation.invitation.user.name ?? t("unknownUser"), + })} +

+
+ + {t("accept", { + mealPlanTitle, + })} + +
+
+
+ )} +
+ ); } diff --git a/src/components/common/Heading.tsx b/src/components/common/Heading.tsx new file mode 100644 index 0000000..ca0b91b --- /dev/null +++ b/src/components/common/Heading.tsx @@ -0,0 +1,5 @@ +import type { FC, PropsWithChildren } from "react"; + +export const Heading: FC> = ({ children }) => ( +

{children}

+); diff --git a/src/components/common/NavBar.tsx b/src/components/common/NavBar.tsx index b53f580..ed98a97 100644 --- a/src/components/common/NavBar.tsx +++ b/src/components/common/NavBar.tsx @@ -89,7 +89,7 @@ export async function NavBar() {
- {currentUser.user && } + {currentUser.user && }
); diff --git a/src/components/common/ProfileImage.tsx b/src/components/common/ProfileImage.tsx index c37b5df..e2c5de4 100644 --- a/src/components/common/ProfileImage.tsx +++ b/src/components/common/ProfileImage.tsx @@ -1,3 +1,4 @@ +import classNames from "classnames"; import type { User } from "next-auth"; import Image from "next/image"; import type { FC } from "react"; @@ -7,10 +8,10 @@ export type UserLike = Pick; type Props = { user: UserLike; - size: number; + withRing?: boolean; }; -export const ProfileImage: FC = ({ user, size }) => { +export const ProfileImage: FC = ({ user, withRing }) => { if (user.image == null) return ; return ( @@ -18,9 +19,12 @@ export const ProfileImage: FC = ({ user, size }) => { {user.name ); diff --git a/src/components/invitation/InvitationHeader.tsx b/src/components/invitation/InvitationHeader.tsx index c851906..bb0e063 100644 --- a/src/components/invitation/InvitationHeader.tsx +++ b/src/components/invitation/InvitationHeader.tsx @@ -8,13 +8,16 @@ import { import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; type Props = { + /** getInvitation Result */ invitation: InvitationReturn; + /** Don't show anything on valid invitations */ + hideOnSuccess?: boolean; }; -export async function InvitationHeader({ invitation }: Props) { +export async function InvitationHeader({ invitation, hideOnSuccess }: Props) { const t = await getScopedI18n("invitation"); - if (invitation == null) + if (invitation.result == "NOT_FOUND") return (
); - if (invitation == "EXPIRED") + if (invitation.result == "EXPIRED") return (
); + if (hideOnSuccess) return <>; + return (

{t("loginToJoinTitle", { - name: invitation.user.name ?? t("unknownUser"), + name: invitation.invitation.user.name ?? t("unknownUser"), mealPlanTitle: await getMealPlanLabel( - invitation.mealPlan, + invitation.invitation.mealPlan, await getI18n() ), })} diff --git a/src/components/mealEntries/MealPlanEntry.tsx b/src/components/mealEntries/MealPlanEntry.tsx index dcd3409..1ac9250 100644 --- a/src/components/mealEntries/MealPlanEntry.tsx +++ b/src/components/mealEntries/MealPlanEntry.tsx @@ -52,7 +52,7 @@ export async function MealPlanEntry({
{users.map((user) => ( - + ))}
diff --git a/src/dal/user/getInvitation.ts b/src/dal/user/getInvitation.ts index 087d1ef..2d6ae40 100644 --- a/src/dal/user/getInvitation.ts +++ b/src/dal/user/getInvitation.ts @@ -1,5 +1,5 @@ import { prisma } from "@/server/db/client"; -import { MealPlan, MealPlanInvite, User } from "@prisma/client"; +import type { MealPlan, MealPlanInvite, User } from "@prisma/client"; /** * Returns if the current invitation is still valid @@ -11,13 +11,33 @@ const isCurrentlyValid = (invitation: MealPlanInvite) => { return invitation.expiresAt >= now; }; -export type InvitationReturn = - | null - | "EXPIRED" - | (MealPlanInvite & { - mealPlan: MealPlan; - user: User; - }); +type SuccessResult = { + result: "OK"; + invitation: MealPlanInvite & { + mealPlan: MealPlan; + user: User; + }; +}; + +type ExpiredResult = { + result: "EXPIRED"; +}; + +type NotFoundResult = { + result: "NOT_FOUND"; +}; + +type JoinedResult = { + result: "JOINED"; + invitation: MealPlanInvite & { + mealPlan: MealPlan; + user: User; + }; +}; + +export type InvitationReturn = SuccessResult | ExpiredResult | NotFoundResult; + +export type UserInvitationReturn = InvitationReturn | JoinedResult; /** * Gets an invitation by its code @@ -26,7 +46,15 @@ export type InvitationReturn = */ export async function getInvitation( invitationCode: string -): Promise { +): Promise; +export async function getInvitation( + invitationCode: string, + userId: string +): Promise; +export async function getInvitation( + invitationCode: string, + userId?: string +): Promise { const invitation = await prisma.mealPlanInvite.findUnique({ where: { invitationCode, @@ -37,8 +65,28 @@ export async function getInvitation( }, }); - if (invitation === null) return null; - if (!isCurrentlyValid(invitation)) return "EXPIRED"; + if (invitation === null) return { result: "NOT_FOUND" }; + if (!isCurrentlyValid(invitation)) return { result: "EXPIRED" }; + + if (userId != undefined) { + // Check if user is already a participant + const participant = await prisma.mealPlanAssignment.findFirst({ + where: { + userId, + mealPlanId: invitation.mealPlanId, + }, + }); + + if (participant != null) { + return { + invitation, + result: "JOINED", + }; + } + } - return invitation; + return { + invitation, + result: "OK", + }; } diff --git a/src/dal/user/redeemMealPlanInvitation.ts b/src/dal/user/redeemMealPlanInvitation.ts index ba1fad6..cf65064 100644 --- a/src/dal/user/redeemMealPlanInvitation.ts +++ b/src/dal/user/redeemMealPlanInvitation.ts @@ -3,7 +3,6 @@ import type { MealPlanInvite } from "@prisma/client"; /** * Redeems a MealPlanInvitation - * @returns The joined MealPlan */ export const redeemMealPlanInvitation = async ( invitation: MealPlanInvite, @@ -17,11 +16,4 @@ export const redeemMealPlanInvitation = async ( userId: targetUserId, }, }); - - // Return MealPlan - return await prisma.mealPlan.findUniqueOrThrow({ - where: { - id: invitation.mealPlanId, - }, - }); }; diff --git a/src/functions/user/getUserId.ts b/src/functions/user/getUserId.ts index 545b8b1..7a93a75 100644 --- a/src/functions/user/getUserId.ts +++ b/src/functions/user/getUserId.ts @@ -10,11 +10,14 @@ type AppSession = Session & { export async function getUserId(): Promise; /** Gets the current UserId (or redirects if there is no user) */ export async function getUserId(withRedirection: true): Promise; -export async function getUserId(withRedirection = false) { +/** Gets the current UserId (or throws an Error) */ +export async function getUserId(throwException: null): Promise; +export async function getUserId(withRedirection: boolean | null = false) { const user = await auth(); if (user == null) { - if (withRedirection) redirect("/"); + if (withRedirection === true) redirect("/"); + if (withRedirection === null) throw new Error("User not found"); return null; } diff --git a/src/locales/de.ts b/src/locales/de.ts index 4aba731..644034d 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -48,5 +48,7 @@ export default { loginToJoinSubtitle: "Melde dich an, um diesem Essensplan beizutreten und gemeinsam Mahlzeiten zu planen", unknownUser: "Ein Benutzer", + header: "Einladung", + accept: "{mealPlanTitle} beitreten", }, } as const; diff --git a/src/locales/en.ts b/src/locales/en.ts index ba08e94..200228d 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -48,5 +48,7 @@ export default { loginToJoinSubtitle: "Sign in to join this meal plan and plan meals together", unknownUser: "A user", + header: "Invitation", + accept: "Join {mealPlanTitle}", }, } as const; From 7ec6088aa47195037280976c823704889f73041c Mon Sep 17 00:00:00 2001 From: Tim Ittermann Date: Sun, 4 Aug 2024 13:32:15 +0200 Subject: [PATCH 15/16] refactor: Remove old artifacts --- .../(userArea)/mealPlan/join/[invitationCode]/page.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/app/[locale]/(userArea)/mealPlan/join/[invitationCode]/page.tsx b/src/app/[locale]/(userArea)/mealPlan/join/[invitationCode]/page.tsx index 3e7a5c3..c85e917 100644 --- a/src/app/[locale]/(userArea)/mealPlan/join/[invitationCode]/page.tsx +++ b/src/app/[locale]/(userArea)/mealPlan/join/[invitationCode]/page.tsx @@ -1,4 +1,3 @@ -import { acceptInvitationAction } from "@/actions/acceptInvitationAction"; import { ProfileImage } from "@/components/common/ProfileImage"; import { InvitationHeader } from "@/components/invitation/InvitationHeader"; import { getInvitation } from "@/dal/user/getInvitation"; @@ -29,11 +28,6 @@ export default async function InvitationPage({ params }: Props) { ? await getMealPlanLabel(invitation.invitation.mealPlan, await getI18n()) : ""; - const acceptAction = - invitation.result === "OK" - ? acceptInvitationAction.bind(null, invitation.invitation.invitationCode) - : undefined; - return (
From ea1b65b14b584040fb9d44a63ffbf2cd0e0307cf Mon Sep 17 00:00:00 2001 From: Tim Ittermann Date: Sun, 4 Aug 2024 13:36:19 +0200 Subject: [PATCH 16/16] fix: Other schema error --- prisma/schema.prisma | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 703f21d..65fa36c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -47,7 +47,7 @@ model MealPlanInvite { createdByUserId String expiresAt DateTime - user User? @relation(fields: [createdByUserId], references: [id], onDelete: Cascade) + user User @relation(fields: [createdByUserId], references: [id], onDelete: Cascade) mealPlan MealPlan @relation(fields: [mealPlanId], references: [id], onDelete: Cascade) }