+218
-9
Diff
round #0
+14
app/pages/index.vue
+14
app/pages/index.vue
···
24
24
});
25
25
26
26
const { isMobile } = useDevice();
27
+
28
+
onMounted(async () => {
29
+
if ("serviceWorker" in navigator) {
30
+
try {
31
+
const reg = await navigator.serviceWorker.register("/sw.js");
32
+
33
+
await navigator.serviceWorker.ready;
34
+
35
+
console.log("SW ist bereit und aktiv:", reg.active);
36
+
} catch (err) {
37
+
console.error("SW registration failed", err);
38
+
}
39
+
}
40
+
});
27
41
</script>
28
42
<template>
29
43
<div>
+9
-1
package.json
+9
-1
package.json
···
22
22
"@iconify-json/material-symbols": "^1.2.64",
23
23
"@internationalized/date": "^3.12.0",
24
24
"@nuxt/icon": "^2.2.1",
25
-
"@nuxtjs/i18n": "^10.2.4",
26
25
"@nuxtjs/device": "^4.0.0",
26
+
"@nuxtjs/i18n": "^10.2.4",
27
27
"@nuxtjs/tailwindcss": "^7.0.0-beta.1",
28
28
"@pinia/nuxt": "^0.11.3",
29
29
"@upstash/redis": "^1.37.0",
···
40
40
"vaul-vue": "^0.4.1",
41
41
"vue": "^3.5.31",
42
42
"vue-router": "^5.0.4",
43
+
"web-push": "^3.6.7",
43
44
"zod": "^4.3.6"
44
45
},
45
46
"devDependencies": {
47
+
"@biomejs/biome": "^2.4.10",
48
+
"@nuxt/test-utils": "^4.0.0",
49
+
"@playwright/test": "^1.58.0",
50
+
"@types/web-push": "^3.6.4",
51
+
"@vue/test-utils": "^2.4.6",
52
+
"happy-dom": "^20.8.9",
53
+
"typescript": "^6.0.2",
+9
public/sw.js
+9
public/sw.js
+14
server/api/subscription/.post.ts
+14
server/api/subscription/.post.ts
···
1
+
import { SubscriptionEventStream } from "~~/server/utils/sse";
2
+
import { PushSubscriptionJSON } from "~~/shared/types";
3
+
4
+
export default defineAuthenticatedEventHandler(
5
+
async (event, userId): Promise<PushSubscriptionJSON> => {
6
+
const body = await readValidatedBody(event, PushSubscriptionJSON.parse);
7
+
8
+
const subscription = await Subscriptions.updateOrAdd(userId, body);
9
+
if (subscription instanceof Error) throw subscription;
10
+
11
+
await SubscriptionEventStream.sendUpdate(userId);
12
+
return subscription;
13
+
},
14
+
);
+14
server/plugins/storage.ts
+14
server/plugins/storage.ts
···
52
52
base: ".data/categories",
53
53
}),
54
54
);
55
+
storage.mount(
56
+
"subscriptions",
57
+
fsDriver({
58
+
base: ".data/subscriptions",
59
+
}),
60
+
);
55
61
}
56
62
57
63
···
81
87
base: "categories",
82
88
}),
83
89
);
90
+
storage.mount(
91
+
"subscriptions",
92
+
upstashDriver({
93
+
url: useRuntimeConfig().upstash.url,
94
+
token: useRuntimeConfig().upstash.token,
95
+
base: "subscriptions",
96
+
}),
97
+
);
84
98
}
+46
server/utils/db/subscription.ts
+46
server/utils/db/subscription.ts
···
1
+
import type {NuxtError} from "nuxt/app";
2
+
import webpush from "web-push";
3
+
import type {PushSubscriptionJSON} from "~~/shared/types";
4
+
5
+
function getKey(userId: string): string {
6
+
return `subscriptions:${userId}`;
7
+
}
8
+
9
+
webpush.setVapidDetails(
10
+
process.env.SUBSCRIPTIONS_MAIL!,
11
+
process.env.NUXT_PUBLIC_SUBSCRIPTIONS_PUBLIC_KEY!,
12
+
process.env.SUBSCRIPTIONS_PRIVATE_KEY!,
13
+
);
14
+
15
+
export const Subscriptions = {
16
+
async getAll(userId: string): Promise<PushSubscriptionJSON[]> {
17
+
const storage = useStorage();
18
+
return (await storage.get<PushSubscriptionJSON[]>(getKey(userId))) ?? [];
19
+
},
20
+
async updateOrAdd(
21
+
userId: string,
22
+
subscription: PushSubscriptionJSON,
23
+
): Promise<PushSubscriptionJSON | NuxtError> {
24
+
const storage = useStorage();
25
+
const subscriptions = await Subscriptions.getAll(userId);
26
+
27
+
const exists = subscriptions.find(
28
+
(s) =>
29
+
s.endpoint === subscription.endpoint ||
30
+
(s.keys.p256dh === subscription.keys.p256dh &&
31
+
s.keys.auth === subscription.keys.auth),
32
+
);
33
+
34
+
if (!exists) {
35
+
subscriptions.push(subscription);
36
+
await storage.set(getKey(userId), subscriptions);
37
+
return subscription;
38
+
}
39
+
40
+
return createError({
41
+
status: 201,
42
+
statusMessage: "Already subscribed",
43
+
message: `Subscription already exists`,
44
+
});
45
+
}
46
+
};
+14
server/utils/sse.ts
+14
server/utils/sse.ts
···
31
31
},
32
32
};
33
33
34
+
export const SubscriptionEventStream = {
35
+
addStream(userId: string, eventStream: EventStream) {
36
+
addEventStream(userId, subscriptionsEventStreams, eventStream);
37
+
},
38
+
async sendUpdate(userId: string) {
39
+
await publish(
40
+
userId,
41
+
subscriptionsEventStreams,
42
+
await Subscriptions.getAll(userId),
43
+
);
44
+
},
45
+
};
46
+
34
47
const todosEventStreams: Map<string, EventStream[]> = new Map();
35
48
const tagsEventStreams: Map<string, EventStream[]> = new Map();
36
49
const categoriesEventStreams: Map<string, EventStream[]> = new Map();
50
+
const subscriptionsEventStreams: Map<string, EventStream[]> = new Map();
37
51
38
52
function addEventStream(
39
53
key: string,
+16
-6
nuxt.config.ts
+16
-6
nuxt.config.ts
···
36
36
37
37
38
38
39
-
40
-
41
-
42
-
43
-
44
-
39
+
runtimeConfig: {
40
+
public: {
41
+
baseURL: process.env.BASE_URL ?? "",
42
+
subscriptionsPublicKey:
43
+
process.env.NUXT_PUBLIC_SUBSCRIPTIONS_PUBLIC_KEY ?? "",
44
+
},
45
+
upstash: {
46
+
url: process.env.UPSTASH_URL,
45
47
46
48
47
49
···
54
56
],
55
57
defaultLocale: "en",
56
58
},
59
+
nitro: {
60
+
experimental: {
61
+
tasks: true,
62
+
},
63
+
scheduledTasks: {
64
+
"* * * * *": ["notify:push"],
65
+
},
66
+
},
57
67
});
+9
server/tasks/notify/push.ts
+9
server/tasks/notify/push.ts
+2
-1
i18n/locales/de.json
+2
-1
i18n/locales/de.json
+2
-1
i18n/locales/en.json
+2
-1
i18n/locales/en.json
+8
README.md
+8
README.md
···
11
11
```dotenv
12
12
GOOGLE_CLIENT_ID=<google_client_id>
13
13
GOOGLE_CLIENT_SECRET=<google_client_secret>
14
+
15
+
NUXT_PUBLIC_SUBSCRIPTIONS_PUBLIC_KEY=<public_key>
16
+
SUBSCRIPTIONS_PRIVATE_KEY=<private_key>
17
+
SUBSCRIPTIONS_MAIL=<mailto:you@example.com>
14
18
```
15
19
16
20
### Deployment
···
24
28
25
29
BETTER_AUTH_SECRET=<secret>
26
30
BETTER_AUTH_URL=<base_url>
31
+
32
+
NUXT_PUBLIC_SUBSCRIPTIONS_PUBLIC_KEY=<public_key>
33
+
SUBSCRIPTIONS_PRIVATE_KEY=<private_key>
34
+
SUBSCRIPTIONS_MAIL=<mailto:you@example.com>
27
35
```
+50
server/utils/subscriptions.ts
+50
server/utils/subscriptions.ts
···
1
+
import type {Todo} from "#shared/types";
2
+
import webpush from "web-push";
3
+
4
+
export async function sendMsg(): Promise<void> {
5
+
const keys = await useStorage().getKeys();
6
+
const filteredKeys = keys.filter((key) => key.startsWith("todos:"));
7
+
8
+
for (const key of filteredKeys) {
9
+
const todos = await useStorage().get<Todo[]>(key);
10
+
11
+
for (const todo of todos!) {
12
+
if (todo.time?.type === "point") {
13
+
const date = new Date(todo.time.time);
14
+
const now = new Date();
15
+
16
+
const sameMinute =
17
+
date.getUTCFullYear() === now.getUTCFullYear() &&
18
+
date.getUTCMonth() === now.getUTCMonth() &&
19
+
date.getUTCDate() === now.getUTCDate() &&
20
+
date.getUTCHours() === now.getUTCHours() &&
21
+
date.getUTCMinutes() === now.getUTCMinutes();
22
+
23
+
if (sameMinute) {
24
+
await sendNotification(
25
+
key.substring(6),
26
+
todo.title,
27
+
todo.note,
28
+
);
29
+
}
30
+
}
31
+
}
32
+
}
33
+
}
34
+
35
+
async function sendNotification(userId: string, title: string, body: string) {
36
+
const subscriptions = await Subscriptions.getAll(userId);
37
+
38
+
const payload = JSON.stringify({
39
+
title: title,
40
+
body: body,
41
+
});
42
+
43
+
for (const sub of subscriptions) {
44
+
try {
45
+
await webpush.sendNotification(sub, payload);
46
+
} catch (err) {
47
+
console.error("Push failed", err);
48
+
}
49
+
}
50
+
}
History
1 round
0 comments
benni043.tngl.sh
submitted
#0
9 commits
expand
collapse
sending messages works
exists check update
added nitro scheduled tasks
lints
works
changes
lints
lints
fixes
merge conflicts detected
expand
collapse
expand
collapse
- app/pages/index.vue:24
- package.json:22
- pnpm-lock.yaml:74