From f69bf920a96a947c14c0b5ba8f9f2c456524b0c0 Mon Sep 17 00:00:00 2001 From: Adeyemi Adetunji Date: Mon, 30 Dec 2024 02:59:10 +0100 Subject: [PATCH] feat: org and course student analytics page (#509) * UI initialization * feat: structure API * feat: audience & people analytics * feat: quick changes * feat: add comments * redesign course student page * chore: make requests to db more efficient * clean up analytics page for course * feat: finish up audience analytics page * chore: minor UI tweaks * translate all * replace inserts of log in events to update * catch error if last_login isn't inserted --------- Co-authored-by: Rotimi Best --- apps/course-app/src/lib/utils.ts | 88 ++--- apps/dashboard/package.json | 4 + .../components/Analytics/ActivityCard.svelte | 34 ++ .../Analytics/HeroProfileCard.svelte | 36 ++ .../components/Analytics/LoadingPage.svelte | 12 + .../src/lib/components/Analytics/index.ts | 3 + .../Apps/components/Poll/index.svelte | 39 +- .../components/Lesson/Exercise/Preview.svelte | 11 +- .../components/Lesson/Exercise/index.svelte | 69 ++-- .../People/DeleteConfirmation.svelte | 6 +- .../components/NewCourseModal/index.svelte | 49 +-- .../lib/components/IconButton/index.svelte | 2 +- .../Org/Audience/AudienceList.svelte | 29 +- .../components/Org/Settings/Profile.svelte | 18 +- .../src/lib/components/Org/SideBar.svelte | 55 +-- .../lib/components/PrimaryButton/index.svelte | 26 +- .../src/lib/components/Progress/index.svelte | 6 +- .../Question/CheckboxQuestion/index.svelte | 12 +- apps/dashboard/src/lib/utils/functions/cn.ts | 56 +++ .../dashboard/src/lib/utils/functions/date.ts | 6 +- .../src/lib/utils/functions/supabase.ts | 7 +- .../src/lib/utils/functions/translations.ts | 15 +- .../src/lib/utils/services/courses/index.ts | 11 + .../services/notification/notification.ts | 7 +- .../src/lib/utils/translations/de.json | 28 +- .../src/lib/utils/translations/en.json | 27 ++ .../src/lib/utils/translations/es.json | 28 +- .../src/lib/utils/translations/fr.json | 28 +- .../src/lib/utils/translations/hi.json | 28 +- .../src/lib/utils/translations/pt.json | 28 +- .../src/lib/utils/translations/ru.json | 28 +- .../src/lib/utils/translations/vi.json | 28 +- .../src/lib/utils/types/analytics.ts | 100 +++++ apps/dashboard/src/lib/utils/types/index.ts | 12 + .../src/routes/api/analytics/user/+server.ts | 288 ++++++++++++++ .../routes/courses/[id]/people/+layout.svelte | 52 +++ .../src/routes/courses/[id]/people/+layout.ts | 6 + .../routes/courses/[id]/people/+page.svelte | 365 +++++++++--------- .../[id]/people/[personId]/+page.svelte | 208 ++++++++++ .../courses/[id]/people/[personId]/+page.ts | 8 + .../src/routes/org/[slug]/+page.svelte | 72 ++-- .../[slug]/audience/[...params]/+page.svelte | 206 ++++++++++ .../org/[slug]/audience/[...params]/+page.ts | 11 + .../static/images/user-placeholder.svg | 9 + pnpm-lock.yaml | 70 +++- .../20241016235827_student_analytics.sql | 122 ++++++ 46 files changed, 1916 insertions(+), 437 deletions(-) create mode 100644 apps/dashboard/src/lib/components/Analytics/ActivityCard.svelte create mode 100644 apps/dashboard/src/lib/components/Analytics/HeroProfileCard.svelte create mode 100644 apps/dashboard/src/lib/components/Analytics/LoadingPage.svelte create mode 100644 apps/dashboard/src/lib/components/Analytics/index.ts create mode 100644 apps/dashboard/src/lib/utils/functions/cn.ts create mode 100644 apps/dashboard/src/lib/utils/types/analytics.ts create mode 100644 apps/dashboard/src/routes/api/analytics/user/+server.ts create mode 100644 apps/dashboard/src/routes/courses/[id]/people/+layout.svelte create mode 100644 apps/dashboard/src/routes/courses/[id]/people/+layout.ts create mode 100644 apps/dashboard/src/routes/courses/[id]/people/[personId]/+page.svelte create mode 100644 apps/dashboard/src/routes/courses/[id]/people/[personId]/+page.ts create mode 100644 apps/dashboard/src/routes/org/[slug]/audience/[...params]/+page.svelte create mode 100644 apps/dashboard/src/routes/org/[slug]/audience/[...params]/+page.ts create mode 100644 apps/dashboard/static/images/user-placeholder.svg create mode 100644 supabase/migrations/20241016235827_student_analytics.sql diff --git a/apps/course-app/src/lib/utils.ts b/apps/course-app/src/lib/utils.ts index 88712453..3f3c44eb 100644 --- a/apps/course-app/src/lib/utils.ts +++ b/apps/course-app/src/lib/utils.ts @@ -1,62 +1,56 @@ -import { type ClassValue, clsx } from "clsx"; -import { twMerge } from "tailwind-merge"; -import { cubicOut } from "svelte/easing"; -import type { TransitionConfig } from "svelte/transition"; +import { type ClassValue, clsx } from 'clsx'; +import { cubicOut } from 'svelte/easing'; +import type { TransitionConfig } from 'svelte/transition'; +import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); + return twMerge(clsx(inputs)); } type FlyAndScaleParams = { - y?: number; - x?: number; - start?: number; - duration?: number; + y?: number; + x?: number; + start?: number; + duration?: number; }; export const flyAndScale = ( - node: Element, - params: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 150 } + node: Element, + params: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 150 } ): TransitionConfig => { - const style = getComputedStyle(node); - const transform = style.transform === "none" ? "" : style.transform; + const style = getComputedStyle(node); + const transform = style.transform === 'none' ? '' : style.transform; - const scaleConversion = ( - valueA: number, - scaleA: [number, number], - scaleB: [number, number] - ) => { - const [minA, maxA] = scaleA; - const [minB, maxB] = scaleB; + const scaleConversion = (valueA: number, scaleA: [number, number], scaleB: [number, number]) => { + const [minA, maxA] = scaleA; + const [minB, maxB] = scaleB; - const percentage = (valueA - minA) / (maxA - minA); - const valueB = percentage * (maxB - minB) + minB; + const percentage = (valueA - minA) / (maxA - minA); + const valueB = percentage * (maxB - minB) + minB; - return valueB; - }; + return valueB; + }; - const styleToString = ( - style: Record - ): string => { - return Object.keys(style).reduce((str, key) => { - if (style[key] === undefined) return str; - return str + `${key}:${style[key]};`; - }, ""); - }; + const styleToString = (style: Record): string => { + return Object.keys(style).reduce((str, key) => { + if (style[key] === undefined) return str; + return str + `${key}:${style[key]};`; + }, ''); + }; - return { - duration: params.duration ?? 200, - delay: 0, - css: (t) => { - const y = scaleConversion(t, [0, 1], [params.y ?? 5, 0]); - const x = scaleConversion(t, [0, 1], [params.x ?? 0, 0]); - const scale = scaleConversion(t, [0, 1], [params.start ?? 0.95, 1]); + return { + duration: params.duration ?? 200, + delay: 0, + css: (t) => { + const y = scaleConversion(t, [0, 1], [params.y ?? 5, 0]); + const x = scaleConversion(t, [0, 1], [params.x ?? 0, 0]); + const scale = scaleConversion(t, [0, 1], [params.start ?? 0.95, 1]); - return styleToString({ - transform: `${transform} translate3d(${x}px, ${y}px, 0) scale(${scale})`, - opacity: t - }); - }, - easing: cubicOut - }; -}; \ No newline at end of file + return styleToString({ + transform: `${transform} translate3d(${x}px, ${y}px, 0) scale(${scale})`, + opacity: t + }); + }, + easing: cubicOut + }; +}; diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 5926c6d8..6a0d7f0c 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -29,12 +29,15 @@ "@sveltejs/adapter-auto": "^2.1.0", "@sveltejs/adapter-node": "^1.3.1", "@sveltejs/adapter-vercel": "^3.0.3", + "@sveltekit-i18n/base": "^1.3.7", + "@sveltekit-i18n/parser-icu": "^1.0.8", "@tailwindcss/forms": "^0.5.4", "@types/pluralize": "0.0.30", "ai": "^2.1.31", "axios": "^1.5.1", "body-parser": "^1.20.2", "canvas-confetti": "^1.6.0", + "clsx": "^2.1.1", "color2k": "^2.0.3", "cookie-parser": "^1.4.6", "copy-to-clipboard": "^3.3.3", @@ -61,6 +64,7 @@ "svelte-meta-tags": "^3.1.2", "svelte-ui": "workspace:*", "sveltekit-i18n": "^2.4.2", + "tailwind-merge": "^2.6.0", "tldts": "^6.1.41", "unsplash-js": "^7.0.18", "wait-on": "^7.0.1", diff --git a/apps/dashboard/src/lib/components/Analytics/ActivityCard.svelte b/apps/dashboard/src/lib/components/Analytics/ActivityCard.svelte new file mode 100644 index 00000000..46d6f40b --- /dev/null +++ b/apps/dashboard/src/lib/components/Analytics/ActivityCard.svelte @@ -0,0 +1,34 @@ + + +
+
+ +
+ +
+

+ {activity.title} +

+

+ {activity.percentage} + {#if !activity.hidePercentage} + % + {/if} +

+

+ {activity.description} +

+
+
diff --git a/apps/dashboard/src/lib/components/Analytics/HeroProfileCard.svelte b/apps/dashboard/src/lib/components/Analytics/HeroProfileCard.svelte new file mode 100644 index 00000000..b78e6f5d --- /dev/null +++ b/apps/dashboard/src/lib/components/Analytics/HeroProfileCard.svelte @@ -0,0 +1,36 @@ + + +
+
+ +
+

+ {user.fullName} +

+ +
+

+ + {user.email} +

+ +

+ + {$t('analytics.last_seen')}: + + {user.lastSeen ? calDateDiff(user.lastSeen) : $t('analytics.a_while_ago')} + +

+
+
+
+
diff --git a/apps/dashboard/src/lib/components/Analytics/LoadingPage.svelte b/apps/dashboard/src/lib/components/Analytics/LoadingPage.svelte new file mode 100644 index 00000000..5d7b88c9 --- /dev/null +++ b/apps/dashboard/src/lib/components/Analytics/LoadingPage.svelte @@ -0,0 +1,12 @@ + + +
+ +
+ + + +
+
diff --git a/apps/dashboard/src/lib/components/Analytics/index.ts b/apps/dashboard/src/lib/components/Analytics/index.ts new file mode 100644 index 00000000..fa6c643f --- /dev/null +++ b/apps/dashboard/src/lib/components/Analytics/index.ts @@ -0,0 +1,3 @@ +export { default as ActivityCard } from './ActivityCard.svelte'; +export { default as HeroProfileCard } from './HeroProfileCard.svelte'; +export { default as LoadingPage } from './LoadingPage.svelte'; diff --git a/apps/dashboard/src/lib/components/Apps/components/Poll/index.svelte b/apps/dashboard/src/lib/components/Apps/components/Poll/index.svelte index 76a36835..628b3aa1 100644 --- a/apps/dashboard/src/lib/components/Apps/components/Poll/index.svelte +++ b/apps/dashboard/src/lib/components/Apps/components/Poll/index.svelte @@ -1,26 +1,26 @@ -
+
- {$t('course.navItem.lessons.exercises.all_exercises.heading')} - + + {$t('course.navItem.lessons.exercises.all_exercises.heading')} + + {$questionnaire.title} @@ -106,7 +119,7 @@ {#if selectedIndex === 0} -
+
{}; @@ -19,10 +19,12 @@ onClose={() => ($deleteMemberModal.open = false)} bind:open={$deleteMemberModal.open} width="w-96" + containerClass="pt-3" + headerClass="py-2 px-5" modalHeading={$t('course.navItem.people.delete_confirmation.title')} >
-

+

{$t('course.navItem.people.delete_confirmation.sure')} {email}?

diff --git a/apps/dashboard/src/lib/components/Courses/components/NewCourseModal/index.svelte b/apps/dashboard/src/lib/components/Courses/components/NewCourseModal/index.svelte index e98586bb..fc886811 100644 --- a/apps/dashboard/src/lib/components/Courses/components/NewCourseModal/index.svelte +++ b/apps/dashboard/src/lib/components/Courses/components/NewCourseModal/index.svelte @@ -1,24 +1,24 @@
+ {:else} +
+ + + {person.email} + +
+ + + + + +
+
+ {/if} + + + + +

+ {$t(ROLE_LABEL[person.role_id])} +

+
+ + + + + + + + + + {/each} + + + diff --git a/apps/dashboard/src/routes/courses/[id]/people/[personId]/+page.svelte b/apps/dashboard/src/routes/courses/[id]/people/[personId]/+page.svelte new file mode 100644 index 00000000..1b5d0bdc --- /dev/null +++ b/apps/dashboard/src/routes/courses/[id]/people/[personId]/+page.svelte @@ -0,0 +1,208 @@ + + +{#if userCourseAnalytics} +
+ + + +
+ {#each learningActivities as activity} + + {/each} +
+
+ +
+

+ {$t('analytics.exercises')} +

+ +
+
+

+ {$t('analytics.exercises')} +

+

+ {$t('analytics.progress_description', { + value: completedExercises, + total: userCourseAnalytics?.userExercisesStats?.length + })} +

+
+ +
+ toggleExerciseFilter('incomplete')} + > + {incompleteExercises} + {$t('analytics.incomplete')} + + toggleExerciseFilter('completed')} + > + {completedExercises} + {$t('analytics.complete')} + +
+
+ + {#each filteredExercises as exercise, index} + {#key index} +
+
+ +
+ +

+ Score: {exercise.score}/{exercise.totalPoints} + + {#if exercise.isCompleted} + + {exercise.status === 3 ? $t('analytics.graded') : $t('analytics.not_graded')} + + {/if} +

+
+
+ + + {exercise.isCompleted ? $t('analytics.complete') : $t('analytics.incomplete')} + +
+ {/key} + {/each} +
+
+{:else} + +{/if} diff --git a/apps/dashboard/src/routes/courses/[id]/people/[personId]/+page.ts b/apps/dashboard/src/routes/courses/[id]/people/[personId]/+page.ts new file mode 100644 index 00000000..c4885d04 --- /dev/null +++ b/apps/dashboard/src/routes/courses/[id]/people/[personId]/+page.ts @@ -0,0 +1,8 @@ +export const load = async ({ params }) => { + const { id, personId } = params; + + return { + userId: personId, + courseId: id + }; +}; diff --git a/apps/dashboard/src/routes/org/[slug]/+page.svelte b/apps/dashboard/src/routes/org/[slug]/+page.svelte index 5863658b..128b14c9 100644 --- a/apps/dashboard/src/routes/org/[slug]/+page.svelte +++ b/apps/dashboard/src/routes/org/[slug]/+page.svelte @@ -1,26 +1,26 @@ + +{#if userAnalytics} +
+ + +
+ + + +
+ {#each userMetrics as activity} + + {/each} +
+
+ +
+

+ {$t('analytics.courses')} +

+ +
+
+

+ {$t('analytics.progress')} +

+

+ {$t('analytics.progress_description', { + value: completedCourses, + total: userAnalytics.courses.length + })} +

+
+ +
+ toggleCourseFilter('incomplete')} + > + {incompleteCourses} + {$t('analytics.incomplete')} + + toggleCourseFilter('completed')} + > + {completedCourses} + {$t('analytics.complete')} + +
+
+ + {#each filteredCourses as course, index} + {#key index} +
+
+
+ {course.title} +
+ +

+ {course.title} +

+
+ +

+ {course.description} +

+
+
+ + + {course.lessons_count === course.lessons_completed + ? $t('analytics.complete') + : $t('analytics.incomplete')} + +
+ +
+ +

{course.progress_percentage}%

+
+
+ {/key} + {/each} +
+
+
+{:else} + +{/if} diff --git a/apps/dashboard/src/routes/org/[slug]/audience/[...params]/+page.ts b/apps/dashboard/src/routes/org/[slug]/audience/[...params]/+page.ts new file mode 100644 index 00000000..f426dacd --- /dev/null +++ b/apps/dashboard/src/routes/org/[slug]/audience/[...params]/+page.ts @@ -0,0 +1,11 @@ +export const load = async ({ params }) => { + const paramParts = params.params?.split('/') ?? []; + + const userId = paramParts[0]; + const orgId = paramParts[1]; + + return { + userId, + orgId + }; +}; diff --git a/apps/dashboard/static/images/user-placeholder.svg b/apps/dashboard/static/images/user-placeholder.svg new file mode 100644 index 00000000..700137d8 --- /dev/null +++ b/apps/dashboard/static/images/user-placeholder.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 445291e3..349e46c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -286,6 +286,12 @@ importers: '@sveltejs/adapter-vercel': specifier: ^3.0.3 version: 3.0.3(@sveltejs/kit@1.29.0) + '@sveltekit-i18n/base': + specifier: ^1.3.7 + version: 1.3.7(svelte@4.1.2) + '@sveltekit-i18n/parser-icu': + specifier: ^1.0.8 + version: 1.0.8 '@tailwindcss/forms': specifier: ^0.5.4 version: 0.5.4(tailwindcss@3.3.2) @@ -304,6 +310,9 @@ importers: canvas-confetti: specifier: ^1.6.0 version: 1.6.0 + clsx: + specifier: ^2.1.1 + version: 2.1.1 color2k: specifier: ^2.0.3 version: 2.0.3 @@ -382,6 +391,9 @@ importers: sveltekit-i18n: specifier: ^2.4.2 version: 2.4.2(svelte@4.1.2) + tailwind-merge: + specifier: ^2.6.0 + version: 2.6.0 tldts: specifier: ^6.1.41 version: 6.1.41 @@ -3938,6 +3950,42 @@ packages: resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==} dev: true + /@formatjs/ecma402-abstract@2.3.1: + resolution: {integrity: sha512-Ip9uV+/MpLXWRk03U/GzeJMuPeOXpJBSB5V1tjA6kJhvqssye5J5LoYLc7Z5IAHb7nR62sRoguzrFiVCP/hnzw==} + dependencies: + '@formatjs/fast-memoize': 2.2.5 + '@formatjs/intl-localematcher': 0.5.9 + decimal.js: 10.4.3 + tslib: 2.8.1 + dev: false + + /@formatjs/fast-memoize@2.2.5: + resolution: {integrity: sha512-6PoewUMrrcqxSoBXAOJDiW1m+AmkrAj0RiXnOMD59GRaswjXhm3MDhgepXPBgonc09oSirAJTsAggzAGQf6A6g==} + dependencies: + tslib: 2.8.1 + dev: false + + /@formatjs/icu-messageformat-parser@2.9.7: + resolution: {integrity: sha512-cuEHyRM5VqLQobANOjtjlgU7+qmk9Q3fDQuBiRRJ3+Wp3ZoZhpUPtUfuimZXsir6SaI2TaAJ+SLo9vLnV5QcbA==} + dependencies: + '@formatjs/ecma402-abstract': 2.3.1 + '@formatjs/icu-skeleton-parser': 1.8.11 + tslib: 2.8.1 + dev: false + + /@formatjs/icu-skeleton-parser@1.8.11: + resolution: {integrity: sha512-8LlHHE/yL/zVJZHAX3pbKaCjZKmBIO6aJY1mkVh4RMSEu/2WRZ4Ysvv3kKXJ9M8RJLBHdnk1/dUQFdod1Dt7Dw==} + dependencies: + '@formatjs/ecma402-abstract': 2.3.1 + tslib: 2.8.1 + dev: false + + /@formatjs/intl-localematcher@0.5.9: + resolution: {integrity: sha512-8zkGu/sv5euxbjfZ/xmklqLyDGQSxsLqg8XOq88JW3cmJtzhCP8EtSJXlaKZnVO4beEaoiT9wj4eIoCQ9smwxA==} + dependencies: + tslib: 2.8.1 + dev: false + /@hapi/hoek@9.3.0: resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} dev: false @@ -6115,7 +6163,7 @@ packages: peerDependencies: '@sveltejs/kit': ^2.0.0 dependencies: - '@sveltejs/kit': 2.10.1(@sveltejs/vite-plugin-svelte@4.0.0)(svelte@5.10.1)(vite@5.4.11) + '@sveltejs/kit': 2.10.1(@sveltejs/vite-plugin-svelte@5.0.0)(svelte@5.10.1)(vite@6.0.0) import-meta-resolve: 4.1.0 dev: true @@ -6403,6 +6451,12 @@ packages: resolution: {integrity: sha512-/gtzLlqm/sox7EoPKD56BxGZktK/syGc79EbJAPWY5KVitQD9SM0TP8yJCqDxTVPk7Lk0WJhrBGUE2Nn0f5M1w==} dev: false + /@sveltekit-i18n/parser-icu@1.0.8: + resolution: {integrity: sha512-/LnvE1EJv+higIxB5cWIV+9neiOe+CfC7VKhpv9mnU35NcZO3yOhEZ8y6F8nHHkMYIABLcqr15yk4hSvmRGWDw==} + dependencies: + intl-messageformat: 10.7.10 + dev: false + /@swc/helpers@0.4.14: resolution: {integrity: sha512-4C7nX/dvpzB7za4Ql9K81xK3HPxCpHMgwTZVyf+9JQ6VUbn9jjZVN7/Nkdz/Ugzs2CSjqnL/UPXroiVBVHUWUw==} dependencies: @@ -9354,7 +9408,6 @@ packages: /decimal.js@10.4.3: resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} - dev: true /decode-named-character-reference@1.0.2: resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==} @@ -11780,6 +11833,15 @@ packages: resolution: {integrity: sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==} dev: false + /intl-messageformat@10.7.10: + resolution: {integrity: sha512-hp7iejCBiJdW3zmOe18FdlJu8U/JsADSDiBPQhfdSeI8B9POtvPRvPh3nMlvhYayGMKLv6maldhR7y3Pf1vkpw==} + dependencies: + '@formatjs/ecma402-abstract': 2.3.1 + '@formatjs/fast-memoize': 2.2.5 + '@formatjs/icu-messageformat-parser': 2.9.7 + tslib: 2.8.1 + dev: false + /ioredis@5.3.2: resolution: {integrity: sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==} engines: {node: '>=12.22.0'} @@ -17153,6 +17215,10 @@ packages: resolution: {integrity: sha512-0LXunzzAZzo0tEPxV3I297ffKZPlKDrjj7NXphC8V5ak9yHC5zRmxnOe2m/Rd/7ivsOMJe3JZ2JVocoDdQTRBA==} dev: true + /tailwind-merge@2.6.0: + resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==} + dev: false + /tailwind-variants@0.2.1(tailwindcss@3.4.16): resolution: {integrity: sha512-2xmhAf4UIc3PijOUcJPA1LP4AbxhpcHuHM2C26xM0k81r0maAO6uoUSHl3APmvHZcY5cZCY/bYuJdfFa4eGoaw==} engines: {node: '>=16.x', pnpm: '>=7.x'} diff --git a/supabase/migrations/20241016235827_student_analytics.sql b/supabase/migrations/20241016235827_student_analytics.sql new file mode 100644 index 00000000..1624f5be --- /dev/null +++ b/supabase/migrations/20241016235827_student_analytics.sql @@ -0,0 +1,122 @@ +create table "public"."analytics_login_events" ( + "id" uuid not null default uuid_generate_v4(), + "user_id" uuid not null, + "logged_in_at" timestamp with time zone default now() +); + +alter table "public"."analytics_login_events" enable row level security; + +CREATE UNIQUE INDEX analytics_login_events_pkey ON public.analytics_login_events USING btree (id); + +CREATE INDEX idx_analytics_login_events_logged_in_at ON public.analytics_login_events USING btree (logged_in_at); + +CREATE INDEX idx_analytics_login_events_user_id ON public.analytics_login_events USING btree (user_id); + +CREATE UNIQUE INDEX analytics_login_events_user_id_unique ON public.analytics_login_events USING btree (user_id); + +alter table "public"."analytics_login_events" add constraint "analytics_login_events_pkey" PRIMARY KEY using index "analytics_login_events_pkey"; + +alter table "public"."analytics_login_events" add constraint "analytics_login_events_user_id_fkey" FOREIGN KEY (user_id) REFERENCES auth.users(id) not valid; + +alter table "public"."analytics_login_events" validate constraint "analytics_login_events_user_id_fkey"; + +alter table "public"."analytics_login_events" add constraint "analytics_login_events_user_id_unique" UNIQUE using index "analytics_login_events_user_id_unique"; + +set check_function_bodies = off; + +CREATE OR REPLACE FUNCTION public.insert_login_event_on_user_login() + RETURNS trigger + LANGUAGE plpgsql + SECURITY DEFINER +AS $function$ +BEGIN + IF (NEW.last_sign_in_at IS NOT NULL) THEN + INSERT INTO public.analytics_login_events (logged_in_at, user_id) + VALUES (NEW.last_sign_in_at, NEW.id) + ON CONFLICT (user_id) DO UPDATE + SET logged_in_at = NEW.last_sign_in_at; + END IF; + RETURN NEW; +END; +$function$ +; + +CREATE TRIGGER insert_login_event_on_user_login_trigger +AFTER UPDATE OF last_sign_in_at ON auth.users +FOR EACH ROW +EXECUTE FUNCTION public.insert_login_event_on_user_login(); + +CREATE OR REPLACE FUNCTION public.insert_login_event_on_user_session_update() + RETURNS trigger + LANGUAGE plpgsql + SECURITY DEFINER +AS $function$ +BEGIN + IF (NEW.updated_at IS NOT NULL) THEN + INSERT INTO public.analytics_login_events (logged_in_at, user_id) + VALUES (NEW.updated_at, NEW.user_id); + END IF; + RETURN NEW; +END; +$function$ +; + +CREATE TRIGGER insert_login_event_on_user_session_update_trigger +AFTER UPDATE OF updated_at ON auth.sessions +FOR EACH ROW +EXECUTE FUNCTION public.insert_login_event_on_user_session_update(); + +grant delete on table "public"."analytics_login_events" to "anon"; + +grant insert on table "public"."analytics_login_events" to "anon"; + +grant references on table "public"."analytics_login_events" to "anon"; + +grant select on table "public"."analytics_login_events" to "anon"; + +grant trigger on table "public"."analytics_login_events" to "anon"; + +grant truncate on table "public"."analytics_login_events" to "anon"; + +grant update on table "public"."analytics_login_events" to "anon"; + +grant delete on table "public"."analytics_login_events" to "authenticated"; + +grant insert on table "public"."analytics_login_events" to "authenticated"; + +grant references on table "public"."analytics_login_events" to "authenticated"; + +grant select on table "public"."analytics_login_events" to "authenticated"; + +grant trigger on table "public"."analytics_login_events" to "authenticated"; + +grant truncate on table "public"."analytics_login_events" to "authenticated"; + +grant update on table "public"."analytics_login_events" to "authenticated"; + +grant delete on table "public"."analytics_login_events" to "service_role"; + +grant insert on table "public"."analytics_login_events" to "service_role"; + +grant references on table "public"."analytics_login_events" to "service_role"; + +grant select on table "public"."analytics_login_events" to "service_role"; + +grant trigger on table "public"."analytics_login_events" to "service_role"; + +grant truncate on table "public"."analytics_login_events" to "service_role"; + +grant update on table "public"."analytics_login_events" to "service_role"; + +create policy "Users can insert their own login events" +on "public"."analytics_login_events" +as permissive +for insert +to authenticated +with check ((auth.uid() = user_id)); + +CREATE POLICY "Enable delete for users based on user_id" +ON "public"."analytics_login_events" +AS PERMISSIVE FOR DELETE +TO public +USING (auth.uid() = user_id);