create-nexgen 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +26 -0
- package/src/index.js +108 -0
- package/template/.dockerignore +14 -0
- package/template/.env +58 -0
- package/template/.env.example +59 -0
- package/template/.prettierignore +5 -0
- package/template/.prettierrc +8 -0
- package/template/README.md +447 -0
- package/template/drizzle.config.ts +29 -0
- package/template/eslint.config.js +52 -0
- package/template/gitignore-stub +24 -0
- package/template/package.json +96 -0
- package/template/public/assets/AuthLayout-CbswhpjJ.js +1 -0
- package/template/public/assets/Button-_7aQ7gHL.js +1 -0
- package/template/public/assets/Input-CLNJXmKc.css +1 -0
- package/template/public/assets/Input-z8GI8Aqo.js +1 -0
- package/template/public/assets/InputPasswordToggle-BxlzVGp3.js +1 -0
- package/template/public/assets/InputPasswordToggle-C77FI9Eg.css +1 -0
- package/template/public/assets/Layout-DotR1sQC.js +1 -0
- package/template/public/assets/Refresh-BdqsPPBC.js +1 -0
- package/template/public/assets/admin-ui-CU34rLdN.js +1 -0
- package/template/public/assets/bootstrap-icons-BeopsB42.woff +0 -0
- package/template/public/assets/bootstrap-icons-mSm7cUeB.woff2 +0 -0
- package/template/public/assets/dashboard-CwybEyLc.js +1 -0
- package/template/public/assets/dashboard-Dc4d-Pi7.css +1 -0
- package/template/public/assets/forgetPassword-CKEJaXsq.js +1 -0
- package/template/public/assets/index-Bleyx5dm.js +64 -0
- package/template/public/assets/index-DUw8E6Yg.css +1 -0
- package/template/public/assets/login-DC7PTlQF.js +1 -0
- package/template/public/assets/realtime-test-BPQdrFym.css +1 -0
- package/template/public/assets/realtime-test-tQZ0rBEJ.js +1 -0
- package/template/public/assets/register-3O7Qs28C.js +1 -0
- package/template/public/assets/resetPassword-A5AzMWKs.js +1 -0
- package/template/public/assets/verifyEmail-DDBEQHOv.js +1 -0
- package/template/public/index.html +17 -0
- package/template/src/database/migrations/mysql/0000_init.sql +73 -0
- package/template/src/database/migrations/mysql/meta/0000_snapshot.json +484 -0
- package/template/src/database/migrations/mysql/meta/_journal.json +13 -0
- package/template/src/database/schema.ts +4 -0
- package/template/src/env.ts +107 -0
- package/template/src/framework/cache/cache.ts +81 -0
- package/template/src/framework/database/connection.ts +168 -0
- package/template/src/framework/database/optional-db-drivers.d.ts +9 -0
- package/template/src/framework/database/paginate.ts +200 -0
- package/template/src/framework/database/schema.ts +26 -0
- package/template/src/framework/database/seed.ts +33 -0
- package/template/src/framework/events/dispatcher.ts +57 -0
- package/template/src/framework/facade.ts +27 -0
- package/template/src/framework/http/app.ts +61 -0
- package/template/src/framework/http/cors.ts +19 -0
- package/template/src/framework/http/logger.ts +85 -0
- package/template/src/framework/http/openapi.ts +34 -0
- package/template/src/framework/http/ratelimiter.ts +13 -0
- package/template/src/framework/http/router.ts +76 -0
- package/template/src/framework/http/static.ts +33 -0
- package/template/src/framework/http/validation.ts +24 -0
- package/template/src/framework/kernel.ts +40 -0
- package/template/src/framework/maker-cli/src/index.mjs +51 -0
- package/template/src/framework/maker-cli/src/levels/level-1/env-db.mjs +57 -0
- package/template/src/framework/maker-cli/src/levels/level-1/file-ops.mjs +30 -0
- package/template/src/framework/maker-cli/src/levels/level-1/flags.mjs +16 -0
- package/template/src/framework/maker-cli/src/levels/level-1/help.mjs +24 -0
- package/template/src/framework/maker-cli/src/levels/level-1/naming.mjs +13 -0
- package/template/src/framework/maker-cli/src/levels/level-1/process.mjs +47 -0
- package/template/src/framework/maker-cli/src/levels/level-2/db/core.mjs +299 -0
- package/template/src/framework/maker-cli/src/levels/level-2/db/index.mjs +177 -0
- package/template/src/framework/maker-cli/src/levels/level-2/deploy/core.mjs +635 -0
- package/template/src/framework/maker-cli/src/levels/level-2/deploy/index.mjs +145 -0
- package/template/src/framework/maker-cli/src/levels/level-2/module/core.mjs +707 -0
- package/template/src/framework/maker-cli/src/levels/level-2/module/index.mjs +116 -0
- package/template/src/framework/maker-cli/src/levels/level-2/runtime/build-frontend.mjs +16 -0
- package/template/src/framework/maker-cli/src/levels/level-2/runtime/core.mjs +311 -0
- package/template/src/framework/maker-cli/src/levels/level-2/runtime/index.mjs +71 -0
- package/template/src/framework/maker-cli/stubs/controller/openapi.ts.stub +55 -0
- package/template/src/framework/maker-cli/stubs/controller/openapi.with-model.ts.stub +56 -0
- package/template/src/framework/maker-cli/stubs/controller/plain.ts.stub +57 -0
- package/template/src/framework/maker-cli/stubs/controller/schema.plain.ts.stub +13 -0
- package/template/src/framework/maker-cli/stubs/controller/schema.ts.stub +32 -0
- package/template/src/framework/maker-cli/stubs/deploy/Dockerfile.bun.stub +49 -0
- package/template/src/framework/maker-cli/stubs/deploy/Dockerfile.pnpm.stub +53 -0
- package/template/src/framework/maker-cli/stubs/deploy/Dockerfile.stub +49 -0
- package/template/src/framework/maker-cli/stubs/deploy/Dockerfile.yarn.stub +53 -0
- package/template/src/framework/maker-cli/stubs/deploy/README.stub +55 -0
- package/template/src/framework/maker-cli/stubs/deploy/compose/mysql.server.stub +29 -0
- package/template/src/framework/maker-cli/stubs/deploy/compose/postgres.server.stub +29 -0
- package/template/src/framework/maker-cli/stubs/deploy/compose/sqlite.stub +29 -0
- package/template/src/framework/maker-cli/stubs/deploy/env/mysql.server.stub +73 -0
- package/template/src/framework/maker-cli/stubs/deploy/env/postgres.server.stub +73 -0
- package/template/src/framework/maker-cli/stubs/deploy/env/sqlite.stub +72 -0
- package/template/src/framework/maker-cli/stubs/deploy/scripts/auto-migrate.sh.stub +15 -0
- package/template/src/framework/maker-cli/stubs/deploy/server/README.stub +77 -0
- package/template/src/framework/maker-cli/stubs/deploy/server/compose/noredis.stub +118 -0
- package/template/src/framework/maker-cli/stubs/deploy/server/compose/redis.dev.stub +131 -0
- package/template/src/framework/maker-cli/stubs/deploy/server/compose/redis.stub +129 -0
- package/template/src/framework/maker-cli/stubs/deploy/server/env/local.example.stub +10 -0
- package/template/src/framework/maker-cli/stubs/deploy/server/env/noredis.stub +24 -0
- package/template/src/framework/maker-cli/stubs/deploy/server/env/redis.stub +24 -0
- package/template/src/framework/maker-cli/stubs/deploy/server/nginx-vhost/README.stub +15 -0
- package/template/src/framework/maker-cli/stubs/deploy/server/nginx-vhost/app.example.com.stub +12 -0
- package/template/src/framework/maker-cli/stubs/deploy/server/pgadmin/servers.stub +13 -0
- package/template/src/framework/maker-cli/stubs/deploy/server/redis/redis.conf.stub +6 -0
- package/template/src/framework/maker-cli/stubs/deploy/supervisor/noredis.stub +53 -0
- package/template/src/framework/maker-cli/stubs/deploy/supervisor/redis.stub +69 -0
- package/template/src/framework/maker-cli/stubs/deploy/workflow/local.json.stub +24 -0
- package/template/src/framework/maker-cli/stubs/deploy/workflow/remote.json.stub +20 -0
- package/template/src/framework/maker-cli/stubs/example/console.ts.stub +33 -0
- package/template/src/framework/maker-cli/stubs/example/controller.ts.stub +503 -0
- package/template/src/framework/maker-cli/stubs/example/job.ts.stub +74 -0
- package/template/src/framework/maker-cli/stubs/example/route.api.ts.stub +206 -0
- package/template/src/framework/maker-cli/stubs/example/schema.ts.stub +41 -0
- package/template/src/framework/maker-cli/stubs/job/name.ts.stub +24 -0
- package/template/src/framework/maker-cli/stubs/model/name.mysql.ts.stub +8 -0
- package/template/src/framework/maker-cli/stubs/model/name.postgresql.ts.stub +8 -0
- package/template/src/framework/maker-cli/stubs/model/name.sqlite.ts.stub +8 -0
- package/template/src/framework/maker-cli/stubs/notification/NotificationBell.vue.stub +218 -0
- package/template/src/framework/maker-cli/stubs/notification/controller.ts.stub +85 -0
- package/template/src/framework/maker-cli/stubs/notification/index.vue.stub +211 -0
- package/template/src/framework/maker-cli/stubs/notification/job.ts.stub +12 -0
- package/template/src/framework/maker-cli/stubs/notification/route.api.ts.stub +49 -0
- package/template/src/framework/maker-cli/stubs/notification/schema.ts.stub +25 -0
- package/template/src/framework/maker-cli/stubs/route/api.ts.stub +79 -0
- package/template/src/framework/maker-cli/stubs/route/plain.ts.stub +10 -0
- package/template/src/framework/maker-cli/stubs/schedule/name.ts.stub +35 -0
- package/template/src/framework/maker-cli/stubs/seeder/name.ts.stub +17 -0
- package/template/src/framework/modules/discover.ts +54 -0
- package/template/src/framework/modules/routes.ts +26 -0
- package/template/src/framework/notification/index.ts +109 -0
- package/template/src/framework/queue/clear.ts +20 -0
- package/template/src/framework/queue/queue.ts +213 -0
- package/template/src/framework/queue/ui.ts +104 -0
- package/template/src/framework/queue/worker.ts +33 -0
- package/template/src/framework/realtime/broadcast.ts +27 -0
- package/template/src/framework/realtime/index.ts +1 -0
- package/template/src/framework/realtime/socket-cookie.ts +65 -0
- package/template/src/framework/realtime/socket.ts +132 -0
- package/template/src/framework/realtime/types.ts +6 -0
- package/template/src/framework/realtime/ui.ts +16 -0
- package/template/src/framework/redis/client.ts +126 -0
- package/template/src/framework/scheduler/lock.ts +124 -0
- package/template/src/framework/scheduler/run.ts +26 -0
- package/template/src/framework/scheduler/scheduler.ts +82 -0
- package/template/src/framework/server.ts +147 -0
- package/template/src/framework/session/session.ts +116 -0
- package/template/src/framework/storage/storage.ts +743 -0
- package/template/src/framework/support/cookie.ts +78 -0
- package/template/src/framework/support/jwt.ts +45 -0
- package/template/src/framework/support/lifecycle.ts +35 -0
- package/template/src/framework/support/logger.ts +102 -0
- package/template/src/framework/support/mail.ts +43 -0
- package/template/src/framework/support/password.ts +23 -0
- package/template/src/framework/support/url.ts +25 -0
- package/template/src/middlewares/auth-middleware.ts +98 -0
- package/template/src/middlewares/role-middleware.ts +24 -0
- package/template/src/modules/auth/controllers/auth.controller.ts +445 -0
- package/template/src/modules/auth/controllers/auth.helpers.ts +110 -0
- package/template/src/modules/auth/controllers/auth.schema.ts +102 -0
- package/template/src/modules/auth/controllers/role.controller.ts +25 -0
- package/template/src/modules/auth/database/models/notifications.ts +22 -0
- package/template/src/modules/auth/database/models/role.ts +14 -0
- package/template/src/modules/auth/database/models/user.ts +46 -0
- package/template/src/modules/auth/database/seeders/role.ts +19 -0
- package/template/src/modules/auth/database/seeders/user.ts +33 -0
- package/template/src/modules/auth/jobs/forgetpass.ts +18 -0
- package/template/src/modules/auth/jobs/registeruser.ts +31 -0
- package/template/src/modules/auth/jobs/verifyemail.ts +18 -0
- package/template/src/modules/auth/routes/api.ts +151 -0
- package/template/src/modules/auth/routes/role.ts +39 -0
- package/template/src/modules/welcome/controllers/welcome.controller.ts +14 -0
- package/template/src/modules/welcome/controllers/welcome.schema.ts +6 -0
- package/template/src/modules/welcome/database/models/welcome.ts +6 -0
- package/template/src/modules/welcome/routes/api.ts +20 -0
- package/template/src/resources/index.html +16 -0
- package/template/src/resources/src/App.vue +5 -0
- package/template/src/resources/src/assets/css/styles.css +14934 -0
- package/template/src/resources/src/assets/css/styles.css.map +1 -0
- package/template/src/resources/src/assets/images/favicon/favicon.ico +0 -0
- package/template/src/resources/src/assets/images/favicon/favicon1.ico +0 -0
- package/template/src/resources/src/assets/images/logo-1.png +0 -0
- package/template/src/resources/src/assets/images/logo-dark-sm.png +0 -0
- package/template/src/resources/src/assets/images/logo-dark.png +0 -0
- package/template/src/resources/src/assets/images/logo-dark1.png +0 -0
- package/template/src/resources/src/assets/images/logo-sm.png +0 -0
- package/template/src/resources/src/assets/images/logo1.png +0 -0
- package/template/src/resources/src/assets/images/logo2.png +0 -0
- package/template/src/resources/src/assets/scss/custom.css +217 -0
- package/template/src/resources/src/assets/scss/custom.css.map +1 -0
- package/template/src/resources/src/assets/scss/custom.scss +1100 -0
- package/template/src/resources/src/components/Button.vue +35 -0
- package/template/src/resources/src/components/Checkbox.vue +29 -0
- package/template/src/resources/src/components/FloatButton.vue +36 -0
- package/template/src/resources/src/components/Href.vue +32 -0
- package/template/src/resources/src/components/Input.vue +227 -0
- package/template/src/resources/src/components/InputGroup.vue +153 -0
- package/template/src/resources/src/components/InputPasswordToggle.vue +226 -0
- package/template/src/resources/src/components/Modal.vue +102 -0
- package/template/src/resources/src/components/Pagebar.vue +28 -0
- package/template/src/resources/src/components/Refresh.vue +26 -0
- package/template/src/resources/src/components/Select.vue +390 -0
- package/template/src/resources/src/components/Spinner.vue +42 -0
- package/template/src/resources/src/components/Switch.vue +65 -0
- package/template/src/resources/src/components/TextArea.vue +121 -0
- package/template/src/resources/src/components/Toast.vue +56 -0
- package/template/src/resources/src/components/datatable/DataTableSkeleton.vue +99 -0
- package/template/src/resources/src/components/datatable/Pagination.vue +161 -0
- package/template/src/resources/src/components/datatable/SelectOpption.vue +54 -0
- package/template/src/resources/src/components/datatable/index.vue +237 -0
- package/template/src/resources/src/composables/useAuth.ts +52 -0
- package/template/src/resources/src/composables/useBrowserDetect.ts +5 -0
- package/template/src/resources/src/composables/useDialog.ts +5 -0
- package/template/src/resources/src/composables/useGum.ts +3 -0
- package/template/src/resources/src/composables/usePulse.ts +5 -0
- package/template/src/resources/src/env.d.ts +20 -0
- package/template/src/resources/src/helpers/nformatter.ts +10 -0
- package/template/src/resources/src/helpers/utils.ts +68 -0
- package/template/src/resources/src/layouts/AuthLayout.vue +20 -0
- package/template/src/resources/src/layouts/Layout/Footer.vue +23 -0
- package/template/src/resources/src/layouts/Layout/Header.vue +90 -0
- package/template/src/resources/src/layouts/Layout/Sidebar.vue +137 -0
- package/template/src/resources/src/layouts/Layout/index.vue +76 -0
- package/template/src/resources/src/main.ts +27 -0
- package/template/src/resources/src/pages/auth/forgetPassword.vue +76 -0
- package/template/src/resources/src/pages/auth/login.vue +93 -0
- package/template/src/resources/src/pages/auth/register.vue +130 -0
- package/template/src/resources/src/pages/auth/resetPassword.vue +119 -0
- package/template/src/resources/src/pages/auth/verifyEmail.vue +60 -0
- package/template/src/resources/src/pages/dashboard/index.vue +76 -0
- package/template/src/resources/src/plugins/axios.ts +33 -0
- package/template/src/resources/src/plugins/browserDetect.ts +55 -0
- package/template/src/resources/src/plugins/dialog.ts +167 -0
- package/template/src/resources/src/plugins/gum.ts +343 -0
- package/template/src/resources/src/plugins/pulse.ts +141 -0
- package/template/src/resources/src/plugins/routeProgress.ts +87 -0
- package/template/src/resources/src/router/index.ts +85 -0
- package/template/src/resources/src/stores/admin-ui.ts +148 -0
- package/template/src/resources/src/stores/auth.ts +151 -0
- package/template/src/resources/tsconfig.json +19 -0
- package/template/src/resources/vite.config.ts +43 -0
- package/template/src/storage/logs/app.log +20179 -0
- package/template/src/storage/logs/fatal.log +727 -0
- package/template/tsconfig.json +20 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Pagebar title="Notifications" />
|
|
3
|
+
<Refresh @click="fetchList" />
|
|
4
|
+
|
|
5
|
+
<div class="notifications-page">
|
|
6
|
+
<div class="card">
|
|
7
|
+
<div class="card-header d-flex align-items-center justify-content-between">
|
|
8
|
+
<div class="d-flex align-items-center gap-2">
|
|
9
|
+
<i class="bi bi-bell"></i>
|
|
10
|
+
<span class="fw-semibold">All Notifications</span>
|
|
11
|
+
<span v-if="unread > 0" class="badge bg-danger">{{ unread }} unread</span>
|
|
12
|
+
</div>
|
|
13
|
+
<div class="d-flex gap-2">
|
|
14
|
+
<button v-if="unread > 0" class="btn btn-sm btn-outline-primary" @click="markAllRead">
|
|
15
|
+
<i class="bi bi-check-all"></i> Mark all read
|
|
16
|
+
</button>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
<div class="card-body p-0">
|
|
20
|
+
<div v-if="loading" class="text-center py-5">
|
|
21
|
+
<div class="spinner-border" role="status"></div>
|
|
22
|
+
</div>
|
|
23
|
+
<div v-else-if="items.length === 0" class="text-center text-secondary py-5">
|
|
24
|
+
<i class="bi bi-bell-slash fs-1 d-block mb-2"></i>
|
|
25
|
+
No notifications yet
|
|
26
|
+
</div>
|
|
27
|
+
<div v-else>
|
|
28
|
+
<div
|
|
29
|
+
v-for="item in items"
|
|
30
|
+
:key="item.id"
|
|
31
|
+
class="notification-row"
|
|
32
|
+
:class="{ unread: !item.readAt }">
|
|
33
|
+
<div class="d-flex align-items-start gap-3 px-3 py-3">
|
|
34
|
+
<i :class="iconClass(item.type)" class="fs-5 mt-1"></i>
|
|
35
|
+
<div class="flex-grow-1 min-w-0">
|
|
36
|
+
<div class="notification-title">{{ item.title }}</div>
|
|
37
|
+
<div v-if="item.body" class="notification-body mt-1">{{ item.body }}</div>
|
|
38
|
+
<div class="notification-meta mt-1">
|
|
39
|
+
<span class="notification-time">{{ formatDate(item.createdAt) }}</span>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
<div class="d-flex gap-1 flex-shrink-0">
|
|
43
|
+
<button
|
|
44
|
+
v-if="!item.readAt"
|
|
45
|
+
class="btn btn-sm btn-outline-secondary"
|
|
46
|
+
title="Mark as read"
|
|
47
|
+
@click="markRead(item)">
|
|
48
|
+
<i class="bi bi-check"></i>
|
|
49
|
+
</button>
|
|
50
|
+
<button
|
|
51
|
+
class="btn btn-sm btn-outline-danger"
|
|
52
|
+
title="Delete"
|
|
53
|
+
@click="remove(item)">
|
|
54
|
+
<i class="bi bi-trash"></i>
|
|
55
|
+
</button>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
<div v-if="total > perPage" class="card-footer d-flex justify-content-center">
|
|
62
|
+
<nav>
|
|
63
|
+
<ul class="pagination pagination-sm mb-0">
|
|
64
|
+
<li class="page-item" :class="{ disabled: page <= 1 }">
|
|
65
|
+
<button class="page-link" @click="goPage(page - 1)">Previous</button>
|
|
66
|
+
</li>
|
|
67
|
+
<li class="page-item" :class="{ disabled: page >= maxPage }">
|
|
68
|
+
<button class="page-link" @click="goPage(page + 1)">Next</button>
|
|
69
|
+
</li>
|
|
70
|
+
</ul>
|
|
71
|
+
</nav>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
</template>
|
|
76
|
+
|
|
77
|
+
<script setup lang="ts">
|
|
78
|
+
import { ref, computed, onMounted, onUnmounted } from "vue";
|
|
79
|
+
import { useHead } from "@vueuse/head";
|
|
80
|
+
import axios from "@/plugins/axios";
|
|
81
|
+
import { pulse } from "@/plugins/pulse";
|
|
82
|
+
import { useAuth } from "@/composables/useAuth";
|
|
83
|
+
import Pagebar from "@/components/Pagebar.vue";
|
|
84
|
+
import Refresh from "@/components/Refresh.vue";
|
|
85
|
+
|
|
86
|
+
useHead({ title: "Notifications" });
|
|
87
|
+
|
|
88
|
+
const { user } = useAuth();
|
|
89
|
+
|
|
90
|
+
const items = ref<any[]>([]);
|
|
91
|
+
const loading = ref(true);
|
|
92
|
+
const page = ref(1);
|
|
93
|
+
const perPage = ref(20);
|
|
94
|
+
const total = ref(0);
|
|
95
|
+
const unread = ref(0);
|
|
96
|
+
|
|
97
|
+
const maxPage = computed(() => Math.max(1, Math.ceil(total.value / perPage.value)));
|
|
98
|
+
|
|
99
|
+
function iconClass(type: string) {
|
|
100
|
+
const map: Record<string, string> = {
|
|
101
|
+
info: "bi bi-info-circle text-primary",
|
|
102
|
+
success: "bi bi-check-circle text-success",
|
|
103
|
+
warning: "bi bi-exclamation-triangle text-warning",
|
|
104
|
+
error: "bi bi-x-circle text-danger",
|
|
105
|
+
};
|
|
106
|
+
return map[type] || "bi bi-bell text-secondary";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function formatDate(dateStr: string) {
|
|
110
|
+
const d = new Date(dateStr);
|
|
111
|
+
return d.toLocaleDateString() + " " + d.toLocaleTimeString();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function fetchList() {
|
|
115
|
+
loading.value = true;
|
|
116
|
+
try {
|
|
117
|
+
const res = await axios.get(`/api/{{module}}?page=${page.value}&perPage=${perPage.value}`);
|
|
118
|
+
items.value = res.data.data;
|
|
119
|
+
total.value = res.data.total;
|
|
120
|
+
unread.value = res.data.unread;
|
|
121
|
+
} catch {}
|
|
122
|
+
loading.value = false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function markRead(item: any) {
|
|
126
|
+
try {
|
|
127
|
+
await axios.patch(`/api/{{module}}/${item.id}/read`);
|
|
128
|
+
item.readAt = new Date().toISOString();
|
|
129
|
+
unread.value = Math.max(0, unread.value - 1);
|
|
130
|
+
} catch {}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function markAllRead() {
|
|
134
|
+
try {
|
|
135
|
+
await axios.patch("/api/{{module}}/read-all");
|
|
136
|
+
items.value.forEach((n) => (n.readAt = new Date().toISOString()));
|
|
137
|
+
unread.value = 0;
|
|
138
|
+
} catch {}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function remove(item: any) {
|
|
142
|
+
try {
|
|
143
|
+
await axios.delete(`/api/{{module}}/${item.id}`);
|
|
144
|
+
items.value = items.value.filter((n) => n.id !== item.id);
|
|
145
|
+
if (!item.readAt) unread.value = Math.max(0, unread.value - 1);
|
|
146
|
+
} catch {}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function goPage(p: number) {
|
|
150
|
+
page.value = Math.max(1, Math.min(p, maxPage.value));
|
|
151
|
+
fetchList();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let cleanup: (() => void) | null = null;
|
|
155
|
+
|
|
156
|
+
onMounted(async () => {
|
|
157
|
+
await fetchList();
|
|
158
|
+
|
|
159
|
+
if (user.value) {
|
|
160
|
+
const channel = pulse.channel(`user:${user.value.id}`);
|
|
161
|
+
channel.listen("notification.created", (payload: any) => {
|
|
162
|
+
items.value.unshift(payload);
|
|
163
|
+
unread.value++;
|
|
164
|
+
});
|
|
165
|
+
cleanup = () => channel.stopListening("notification.created");
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
onUnmounted(() => {
|
|
170
|
+
cleanup?.();
|
|
171
|
+
});
|
|
172
|
+
</script>
|
|
173
|
+
|
|
174
|
+
<style scoped>
|
|
175
|
+
.notifications-page {
|
|
176
|
+
padding: 0.5rem;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.notification-row {
|
|
180
|
+
border-bottom: 1px solid var(--app-border);
|
|
181
|
+
transition: background 0.15s;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.notification-row:hover {
|
|
185
|
+
background: var(--app-surface);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.notification-row.unread {
|
|
189
|
+
background: rgba(var(--bs-primary-rgb), 0.04);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.notification-title {
|
|
193
|
+
font-weight: 500;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.notification-body {
|
|
197
|
+
font-size: 0.9rem;
|
|
198
|
+
color: var(--bs-secondary-color);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.notification-time {
|
|
202
|
+
font-size: 0.8rem;
|
|
203
|
+
color: var(--bs-secondary-color);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
@media (max-width: 768px) {
|
|
207
|
+
.notifications-page {
|
|
208
|
+
padding: 0;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
</style>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { mail } from "@/framework/support/mail.js";
|
|
2
|
+
import { logger } from "@/framework/support/logger.js";
|
|
3
|
+
import { shouldQueue } from "@/framework/queue/queue.js";
|
|
4
|
+
|
|
5
|
+
shouldQueue("{{module}}:mail", "mail", async (job) => {
|
|
6
|
+
const { to, subject, html } = job.data;
|
|
7
|
+
|
|
8
|
+
await mail.sendMail({ to, subject, html });
|
|
9
|
+
logger.info("Notification mail sent", { to, subject });
|
|
10
|
+
|
|
11
|
+
return { ok: true };
|
|
12
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createRoute, createRouter, HttpStatusCodes, jsonContent, z } from "@/framework/facade.js";
|
|
2
|
+
import { authMiddleware } from "@/middlewares/auth-middleware.js";
|
|
3
|
+
import { list, unreadCount, markRead, markAllRead, remove } from "../controllers/{{module}}.controller.js";
|
|
4
|
+
import { PaginatedNotificationsSchema, UnreadCountSchema } from "../controllers/{{module}}.schema.js";
|
|
5
|
+
|
|
6
|
+
const listRoute = createRoute({
|
|
7
|
+
path: "/",
|
|
8
|
+
method: "get",
|
|
9
|
+
tags: ["Notifications"],
|
|
10
|
+
responses: { [HttpStatusCodes.OK]: jsonContent(PaginatedNotificationsSchema, "Paginated notifications") },
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const unreadCountRoute = createRoute({
|
|
14
|
+
path: "/unread-count",
|
|
15
|
+
method: "get",
|
|
16
|
+
tags: ["Notifications"],
|
|
17
|
+
responses: { [HttpStatusCodes.OK]: jsonContent(UnreadCountSchema, "Unread count") },
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const markReadRoute = createRoute({
|
|
21
|
+
path: "/{id}/read",
|
|
22
|
+
method: "patch",
|
|
23
|
+
tags: ["Notifications"],
|
|
24
|
+
request: { params: z.object({ id: z.coerce.number() }) },
|
|
25
|
+
responses: { [HttpStatusCodes.OK]: jsonContent(z.object({ ok: z.boolean() }), "Marked as read") },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const markAllReadRoute = createRoute({
|
|
29
|
+
path: "/read-all",
|
|
30
|
+
method: "patch",
|
|
31
|
+
tags: ["Notifications"],
|
|
32
|
+
responses: { [HttpStatusCodes.OK]: jsonContent(z.object({ ok: z.boolean() }), "All marked as read") },
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const deleteRoute = createRoute({
|
|
36
|
+
path: "/{id}",
|
|
37
|
+
method: "delete",
|
|
38
|
+
tags: ["Notifications"],
|
|
39
|
+
request: { params: z.object({ id: z.coerce.number() }) },
|
|
40
|
+
responses: { [HttpStatusCodes.OK]: jsonContent(z.object({ ok: z.boolean() }), "Deleted") },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export default createRouter()
|
|
44
|
+
.group(authMiddleware)
|
|
45
|
+
.api(listRoute, list)
|
|
46
|
+
.api(unreadCountRoute, unreadCount)
|
|
47
|
+
.api(markReadRoute, markRead)
|
|
48
|
+
.api(markAllReadRoute, markAllRead)
|
|
49
|
+
.api(deleteRoute, remove);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from "@hono/zod-openapi";
|
|
2
|
+
|
|
3
|
+
export const NotificationSchema = z.object({
|
|
4
|
+
id: z.number(),
|
|
5
|
+
userId: z.number(),
|
|
6
|
+
type: z.string(),
|
|
7
|
+
title: z.string(),
|
|
8
|
+
body: z.string().nullable(),
|
|
9
|
+
data: z.any().nullable(),
|
|
10
|
+
link: z.string().nullable(),
|
|
11
|
+
readAt: z.string().nullable(),
|
|
12
|
+
createdAt: z.string(),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const UnreadCountSchema = z.object({
|
|
16
|
+
count: z.number(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const PaginatedNotificationsSchema = z.object({
|
|
20
|
+
data: z.array(NotificationSchema),
|
|
21
|
+
total: z.number(),
|
|
22
|
+
unread: z.number(),
|
|
23
|
+
page: z.number(),
|
|
24
|
+
perPage: z.number(),
|
|
25
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { createRoute, group, HttpStatusCodes, jsonContent } from "@/framework/facade.js";
|
|
2
|
+
import { requireRole } from "@/middlewares/role-middleware.js";
|
|
3
|
+
import { index, show, store, update, destroy } from "@/modules/{{module}}/controllers/{{controller}}.controller.js";
|
|
4
|
+
import {
|
|
5
|
+
Create{{ClassName}}Schema,
|
|
6
|
+
Update{{ClassName}}Schema,
|
|
7
|
+
{{ClassName}}IdParamsSchema,
|
|
8
|
+
{{ClassName}}ListResponseSchema,
|
|
9
|
+
{{ClassName}}MessageSchema,
|
|
10
|
+
{{ClassName}}ResponseSchema
|
|
11
|
+
} from "@/modules/{{module}}/controllers/{{controller}}.schema.js";
|
|
12
|
+
|
|
13
|
+
const indexRoute = createRoute({
|
|
14
|
+
path: "/",
|
|
15
|
+
method: "get",
|
|
16
|
+
tags: ["{{ModuleClass}}"],
|
|
17
|
+
responses: {
|
|
18
|
+
[HttpStatusCodes.OK]: jsonContent({{ClassName}}ListResponseSchema, "{{module}} list")
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const showRoute = createRoute({
|
|
23
|
+
path: "/{id}",
|
|
24
|
+
method: "get",
|
|
25
|
+
tags: ["{{ModuleClass}}"],
|
|
26
|
+
request: {
|
|
27
|
+
params: {{ClassName}}IdParamsSchema
|
|
28
|
+
},
|
|
29
|
+
responses: {
|
|
30
|
+
[HttpStatusCodes.OK]: jsonContent({{ClassName}}ResponseSchema, "{{module}} item"),
|
|
31
|
+
[HttpStatusCodes.NOT_FOUND]: jsonContent({{ClassName}}MessageSchema, "Not found")
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const storeRoute = createRoute({
|
|
36
|
+
path: "/",
|
|
37
|
+
method: "post",
|
|
38
|
+
tags: ["{{ModuleClass}}"],
|
|
39
|
+
request: {
|
|
40
|
+
body: jsonContent(Create{{ClassName}}Schema, "Create {{module}} payload")
|
|
41
|
+
},
|
|
42
|
+
responses: {
|
|
43
|
+
[HttpStatusCodes.CREATED]: jsonContent({{ClassName}}MessageSchema, "Created")
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const updateRoute = createRoute({
|
|
48
|
+
path: "/{id}",
|
|
49
|
+
method: "put",
|
|
50
|
+
tags: ["{{ModuleClass}}"],
|
|
51
|
+
request: {
|
|
52
|
+
params: {{ClassName}}IdParamsSchema,
|
|
53
|
+
body: jsonContent(Update{{ClassName}}Schema, "Update {{module}} payload")
|
|
54
|
+
},
|
|
55
|
+
responses: {
|
|
56
|
+
[HttpStatusCodes.OK]: jsonContent({{ClassName}}MessageSchema, "Updated"),
|
|
57
|
+
[HttpStatusCodes.NOT_FOUND]: jsonContent({{ClassName}}MessageSchema, "Not found")
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const destroyRoute = createRoute({
|
|
62
|
+
path: "/{id}",
|
|
63
|
+
method: "delete",
|
|
64
|
+
tags: ["{{ModuleClass}}"],
|
|
65
|
+
request: {
|
|
66
|
+
params: {{ClassName}}IdParamsSchema
|
|
67
|
+
},
|
|
68
|
+
responses: {
|
|
69
|
+
[HttpStatusCodes.OK]: jsonContent({{ClassName}}MessageSchema, "Deleted"),
|
|
70
|
+
[HttpStatusCodes.NOT_FOUND]: jsonContent({{ClassName}}MessageSchema, "Not found")
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
export default group()
|
|
75
|
+
.api(indexRoute, index)
|
|
76
|
+
.api(showRoute, show)
|
|
77
|
+
.api(storeRoute, store)
|
|
78
|
+
.api(updateRoute, update)
|
|
79
|
+
.api(destroyRoute, destroy);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { group } from "@/framework/facade.js";
|
|
2
|
+
import { requireRole } from "@/middlewares/role-middleware.js";
|
|
3
|
+
import { index, show, store, update, destroy } from "@/modules/{{module}}/controllers/{{controller}}.controller.js";
|
|
4
|
+
|
|
5
|
+
export default group()
|
|
6
|
+
.get("/", index)
|
|
7
|
+
.get("/:id", show)
|
|
8
|
+
.post("/", store)
|
|
9
|
+
.put("/:id", update)
|
|
10
|
+
.delete("/:id", destroy);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { DateTime } from "luxon";
|
|
2
|
+
import { defineSchedule, dispatchEvent } from "@/framework/facade.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* name: Unique schedule id used as the distributed lock key.
|
|
6
|
+
* expression: Cron expression that controls when the task runs.
|
|
7
|
+
* timezone: IANA timezone for cron evaluation (example: "UTC").
|
|
8
|
+
* runOnInit: Runs once immediately when scheduler starts.
|
|
9
|
+
* enabled: Enables or disables this schedule without deleting code.
|
|
10
|
+
* ttlMs: Lock TTL in milliseconds to avoid duplicate execution.
|
|
11
|
+
*/
|
|
12
|
+
defineSchedule({
|
|
13
|
+
name: "{{module}}:{{name}}",
|
|
14
|
+
expression: "0 * * * * *",
|
|
15
|
+
// timezone: "UTC",
|
|
16
|
+
// runOnInit: false,
|
|
17
|
+
// enabled: true,
|
|
18
|
+
// ttlMs: 120000,
|
|
19
|
+
handler: async () => {
|
|
20
|
+
/**
|
|
21
|
+
* dispatchEvent name: Event id to publish to listeners.
|
|
22
|
+
* payload: Data sent to queue/broadcast consumers.
|
|
23
|
+
* queue: Optional queue target for async event consumers.
|
|
24
|
+
* broadcast: Optional realtime fanout (all/users/roles/rooms).
|
|
25
|
+
*/
|
|
26
|
+
// await dispatchEvent("{{module}}.{{name}}", {
|
|
27
|
+
// origin: "scheduler",
|
|
28
|
+
// createdAt: DateTime.now()
|
|
29
|
+
// }, {
|
|
30
|
+
// queue: "default"
|
|
31
|
+
// });
|
|
32
|
+
|
|
33
|
+
DateTime.now().toISO();
|
|
34
|
+
}
|
|
35
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { db } from "@/framework/facade.js";
|
|
2
|
+
import { {{tableVariable}} } from "@/modules/{{module}}/database/models/{{name}}.js";
|
|
3
|
+
|
|
4
|
+
export default async function {{ClassName}}Seeder() {
|
|
5
|
+
const rows = [
|
|
6
|
+
// Add your seed data here
|
|
7
|
+
// Example:
|
|
8
|
+
// { name: "{{ClassName}} 1", description: "This is the first {{name}}" },
|
|
9
|
+
// { name: "{{ClassName}} 2", description: "This is the second {{name}}" }
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
for (const row of rows) {
|
|
13
|
+
await db.insert({{tableVariable}}).values(row);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
console.log("{{ClassName}} seeder completed");
|
|
17
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { glob } from "glob";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
|
|
6
|
+
function resolveModulesPath() {
|
|
7
|
+
const cwd = process.cwd();
|
|
8
|
+
const srcModules = path.resolve(cwd, "src/modules");
|
|
9
|
+
const distModules = path.resolve(cwd, "dist/src/modules");
|
|
10
|
+
const entry = String(process.argv[1] || "");
|
|
11
|
+
const runningDist = /[\\/]dist[\\/]/.test(entry);
|
|
12
|
+
|
|
13
|
+
if (runningDist && fs.existsSync(distModules)) return distModules;
|
|
14
|
+
return srcModules;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const modulesPath = resolveModulesPath();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Why: Finds module files by glob from active src/dist modules path.
|
|
21
|
+
* When: Framework needs module routes/jobs/seeders/schedules discovery.
|
|
22
|
+
* Where: Route registrar, queue boot, scheduler boot, seeding.
|
|
23
|
+
* How: Runs glob with absolute paths and stable sorting.
|
|
24
|
+
*/
|
|
25
|
+
export async function discoverModuleFiles(pattern: string) {
|
|
26
|
+
const files = await glob(pattern, {
|
|
27
|
+
cwd: modulesPath,
|
|
28
|
+
absolute: true,
|
|
29
|
+
nodir: true,
|
|
30
|
+
windowsPathsNoEscape: true
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return files.sort();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Why: Derives module folder name from absolute discovered file path.
|
|
38
|
+
* When: Mounting route prefixes and grouping module assets.
|
|
39
|
+
* Where: Route registration logic.
|
|
40
|
+
* How: Computes relative path and returns first segment.
|
|
41
|
+
*/
|
|
42
|
+
export function moduleNameFromPath(filePath: string) {
|
|
43
|
+
return path.relative(modulesPath, filePath).split(path.sep)[0];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Why: Dynamically imports a discovered module file.
|
|
48
|
+
* When: Bootstrapping pluggable module artifacts.
|
|
49
|
+
* Where: Discovery consumers across framework.
|
|
50
|
+
* How: Converts file path to file URL and imports it.
|
|
51
|
+
*/
|
|
52
|
+
export async function importFile(filePath: string) {
|
|
53
|
+
return await import(pathToFileURL(filePath).href);
|
|
54
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { OpenAPIHono } from "@hono/zod-openapi";
|
|
2
|
+
import {
|
|
3
|
+
discoverModuleFiles,
|
|
4
|
+
importFile,
|
|
5
|
+
moduleNameFromPath
|
|
6
|
+
} from "@/framework/modules/discover.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Why: Auto-registers module route files under `/api/<module>`.
|
|
10
|
+
* When: HTTP app bootstraps module routing.
|
|
11
|
+
* Where: Kernel startup.
|
|
12
|
+
* How: Discovers route files, imports defaults, mounts by module name.
|
|
13
|
+
*/
|
|
14
|
+
export async function registerModuleRoutes(app: OpenAPIHono) {
|
|
15
|
+
const files = await discoverModuleFiles("**/routes/*.{ts,js}");
|
|
16
|
+
|
|
17
|
+
for (const file of files) {
|
|
18
|
+
const route = await importFile(file);
|
|
19
|
+
|
|
20
|
+
if (route.default) {
|
|
21
|
+
app.route(`/api/${moduleNameFromPath(file)}`, route.default);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return files.length;
|
|
26
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { eq } from "drizzle-orm";
|
|
2
|
+
import { db } from "@/framework/database/connection.js";
|
|
3
|
+
import { dispatchEvent } from "@/framework/events/dispatcher.js";
|
|
4
|
+
import { mail } from "@/framework/support/mail.js";
|
|
5
|
+
import { redisClientIfReady } from "@/framework/redis/client.js";
|
|
6
|
+
import { notifications } from "@/modules/auth/database/models/notifications.js";
|
|
7
|
+
import { users } from "@/modules/auth/database/models/user.js";
|
|
8
|
+
|
|
9
|
+
/** Extra JSON metadata attached to the notification (e.g. entity IDs, links). */
|
|
10
|
+
export type NotificationOptions = {
|
|
11
|
+
/** Notification category key (default: "info") */
|
|
12
|
+
type?: string;
|
|
13
|
+
/** Notification title */
|
|
14
|
+
title: string;
|
|
15
|
+
/** Notification body text */
|
|
16
|
+
body?: string;
|
|
17
|
+
/** Optional click-through URL */
|
|
18
|
+
link?: string;
|
|
19
|
+
/** Arbitrary JSON payload stored in `data` column */
|
|
20
|
+
data?: Record<string, unknown>;
|
|
21
|
+
/** Whether to broadcast the notification to the user's socket in realtime */
|
|
22
|
+
broadcast?: boolean;
|
|
23
|
+
/** Email delivery options */
|
|
24
|
+
mail?: {
|
|
25
|
+
subject?: string;
|
|
26
|
+
html?: string;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Create a notification and persist it to the database. Optionally broadcast via Socket.IO and/or send by email. */
|
|
31
|
+
export async function notify(userId: number, options: NotificationOptions) {
|
|
32
|
+
const insertResult = await db.insert(notifications).values({
|
|
33
|
+
userId,
|
|
34
|
+
type: options.type || "info",
|
|
35
|
+
title: options.title,
|
|
36
|
+
body: options.body || null,
|
|
37
|
+
link: options.link || null,
|
|
38
|
+
data: options.data ? JSON.stringify(options.data) : null,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const insertedId = Number((insertResult as any)[0]?.insertId ?? (insertResult as any).insertId);
|
|
42
|
+
if (!insertedId) throw new Error("Failed to create notification");
|
|
43
|
+
|
|
44
|
+
const [row] = await db.select().from(notifications).where(eq(notifications.id, insertedId));
|
|
45
|
+
const normalized = normalize(row);
|
|
46
|
+
|
|
47
|
+
if (options.broadcast) {
|
|
48
|
+
await dispatchEvent("notification.created", normalized, {
|
|
49
|
+
broadcast: { users: [userId] }
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (options.mail) {
|
|
54
|
+
await sendMailChannel(userId, normalized, options);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return normalized;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Deliver an email notification, either queued (if Redis is available) or direct. */
|
|
61
|
+
async function sendMailChannel(
|
|
62
|
+
userId: number,
|
|
63
|
+
notification: { id: number; title: string; body: string | null; },
|
|
64
|
+
options: NotificationOptions
|
|
65
|
+
) {
|
|
66
|
+
const [user] = await db.select({ email: users.email, name: users.name })
|
|
67
|
+
.from(users).where(eq(users.id, userId));
|
|
68
|
+
|
|
69
|
+
if (!user?.email) return;
|
|
70
|
+
|
|
71
|
+
const subject = options.mail?.subject || notification.title;
|
|
72
|
+
const html = options.mail?.html || defaultMailHtml(notification.title, notification.body);
|
|
73
|
+
|
|
74
|
+
if (redisClientIfReady()) {
|
|
75
|
+
await dispatchEvent("notification:mail", {
|
|
76
|
+
to: user.email, subject, html,
|
|
77
|
+
}, { queue: "mail" });
|
|
78
|
+
} else {
|
|
79
|
+
await mail.sendMail({ to: user.email, subject, html });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Generate a basic HTML email body from title and body text. */
|
|
84
|
+
function defaultMailHtml(title: string, body: string | null) {
|
|
85
|
+
return `<h2>${escapeHtml(title)}</h2>${body ? `<p>${escapeHtml(body)}</p>` : ""}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function escapeHtml(str: string) {
|
|
89
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Convert a DB row into the normalized response shape, parsing the `data` JSON column. */
|
|
93
|
+
function normalize(row: typeof notifications.$inferSelect) {
|
|
94
|
+
let parsed: unknown = null;
|
|
95
|
+
if (row.data) {
|
|
96
|
+
try { parsed = JSON.parse(row.data); } catch { }
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
id: row.id,
|
|
100
|
+
userId: row.userId,
|
|
101
|
+
type: row.type,
|
|
102
|
+
title: row.title,
|
|
103
|
+
body: row.body,
|
|
104
|
+
data: parsed,
|
|
105
|
+
link: row.link,
|
|
106
|
+
readAt: row.readAt?.toISOString() || null,
|
|
107
|
+
createdAt: row.createdAt.toISOString(),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { initRedis, redisError, redisReady } from "@/framework/redis/client.js";
|
|
2
|
+
import { logger } from "@/framework/support/logger.js";
|
|
3
|
+
import { clearQueue } from "@/framework/queue/queue.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Why: Clears Redis queue keys used by BullMQ runtime.
|
|
7
|
+
* When: CLI executes `queue:clear` maintenance command.
|
|
8
|
+
* Where: Framework queue maintenance script.
|
|
9
|
+
* How: Initializes Redis, skips safely if unavailable, then deletes prefixed keys.
|
|
10
|
+
*/
|
|
11
|
+
await initRedis();
|
|
12
|
+
if (!redisReady()) {
|
|
13
|
+
logger.warn("Queue clear skipped because Redis is unavailable", {
|
|
14
|
+
error: redisError()
|
|
15
|
+
});
|
|
16
|
+
process.exit(0);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
await clearQueue();
|
|
20
|
+
console.log("Queue cleared");
|