Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified bun.lockb
Binary file not shown.
8 changes: 8 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import type * as schemas_toolSchema from "../schemas/toolSchema.js";
import type * as schemas_topUpSchema from "../schemas/topUpSchema.js";
import type * as schemas_transactionSchema from "../schemas/transactionSchema.js";
import type * as schemas_userSchema from "../schemas/userSchema.js";
import type * as schemas_webPushSubscriptionSchema from "../schemas/webPushSubscriptionSchema.js";
import type * as skills_builtIn_askForClarification from "../skills/builtIn/askForClarification.js";
import type * as skills_builtIn_cancelSchedule from "../skills/builtIn/cancelSchedule.js";
import type * as skills_builtIn_createSkill from "../skills/builtIn/createSkill.js";
Expand Down Expand Up @@ -90,6 +91,9 @@ import type * as users_private from "../users/private.js";
import type * as users_public from "../users/public.js";
import type * as users_requests_private from "../users/requests/private.js";
import type * as users_requests_public from "../users/requests/public.js";
import type * as webPushSubscriptions_notifications from "../webPushSubscriptions/notifications.js";
import type * as webPushSubscriptions_private from "../webPushSubscriptions/private.js";
import type * as webPushSubscriptions_public from "../webPushSubscriptions/public.js";

import type {
ApiFromModules,
Expand Down Expand Up @@ -139,6 +143,7 @@ declare const fullApi: ApiFromModules<{
"schemas/topUpSchema": typeof schemas_topUpSchema;
"schemas/transactionSchema": typeof schemas_transactionSchema;
"schemas/userSchema": typeof schemas_userSchema;
"schemas/webPushSubscriptionSchema": typeof schemas_webPushSubscriptionSchema;
"skills/builtIn/askForClarification": typeof skills_builtIn_askForClarification;
"skills/builtIn/cancelSchedule": typeof skills_builtIn_cancelSchedule;
"skills/builtIn/createSkill": typeof skills_builtIn_createSkill;
Expand Down Expand Up @@ -187,6 +192,9 @@ declare const fullApi: ApiFromModules<{
"users/public": typeof users_public;
"users/requests/private": typeof users_requests_private;
"users/requests/public": typeof users_requests_public;
"webPushSubscriptions/notifications": typeof webPushSubscriptions_notifications;
"webPushSubscriptions/private": typeof webPushSubscriptions_private;
"webPushSubscriptions/public": typeof webPushSubscriptions_public;
}>;
declare const fullApiWithMounts: typeof fullApi;

Expand Down
9 changes: 9 additions & 0 deletions convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { taskSchema } from './schemas/taskSchema';
import { topUpSchema } from './schemas/topUpSchema';
import { transactionSchema } from './schemas/transactionSchema';
import { userPreferencesSchema, userRequestSchema, userSchema } from './schemas/userSchema';
import { webPushSubscriptionSchema } from './schemas/webPushSubscriptionSchema';

// prettier-ignore
export default defineSchema({
Expand Down Expand Up @@ -39,6 +40,14 @@ export default defineSchema({
'by_owner_key', ['owner', 'key'],
),

webPushSubscriptions: defineTable(
zodToConvex(webPushSubscriptionSchema),
).index(
'by_user', ['userId'],
).index(
'by_endpoint', ['subscription.endpoint'],
),

tasks: defineTable(
zodToConvex(taskSchema),
).index(
Expand Down
4 changes: 4 additions & 0 deletions convex/schemas/envSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ export const env = createEnv({
.pipe(z.array(z.string()))
.describe('Comma-separated list of allowed domains to sign in with.'),

WEB_PUSH_VAPID_PUBLIC_KEY: z.string().min(1).describe('VAPID public key for web push notifications.'),
WEB_PUSH_VAPID_PRIVATE_KEY: z.string().min(1).describe('VAPID private key for web push notifications.'),
WEB_PUSH_CONTACT_EMAIL: z.string().email().describe('Contact email for web push notifications.'),

ALLOWED_EMAILS: z
.string()
.min(1)
Expand Down
17 changes: 17 additions & 0 deletions convex/schemas/webPushSubscriptionSchema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { zid } from 'convex-helpers/server/zod';
import { z } from 'zod';

export const webPushSubscriptionSchema = z.object({
userId: zid('users'),
subscription: z.object({
endpoint: z.string().url(),
keys: z.object({
p256dh: z.string(),
auth: z.string(),
}),
}),
userAgent: z.string().optional(),
createdAt: z.number().default(() => Date.now()),
lastUsedAt: z.number().default(() => Date.now()),
isEnabled: z.boolean().default(true),
});
15 changes: 12 additions & 3 deletions convex/tasks/private.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,11 +388,13 @@ export const _setStatus = internalMutation({
},
handler: async (ctx, { taskId, newStatus }) => {
//
const task = await _findOne(ctx, { taskId });
if (!task) throw new Error('Task not found');

const oldStatus = task.status;

if (newStatus === 'done' || newStatus === 'discarded') {
//
const task = await _findOne(ctx, { taskId });
if (!task) throw new Error('Task not found');

// remove funds from the task
if (task.budgetUSDC.available > 0n) {
await _removeFunds(ctx, { taskId, amount: task.budgetUSDC.available });
Expand All @@ -405,6 +407,13 @@ export const _setStatus = internalMutation({
}
}

// TODO: send push notification if status changed to unread or blocked
if (oldStatus !== newStatus && (newStatus === 'unread' || newStatus === 'blocked')) {
console.debug(
`Task ${taskId} status changed from ${oldStatus} to ${newStatus}, will add notification once API is generated`,
);
}

return await ctx.db.patch(taskId, {
status: newStatus,
isActive: newStatus !== 'done' && newStatus !== 'discarded',
Expand Down
163 changes: 163 additions & 0 deletions convex/webPushSubscriptions/notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { z } from 'zod';
import { Id } from '../_generated/dataModel';
import { internalAction, internalMutation, internalQuery } from '../lib';
import { env } from '../schemas/envSchema';

// Helper functions for Web Push Protocol implementation
const base64URLEncode = (str: ArrayBuffer): string => {
//
const bytes = new Uint8Array(str);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
};

const base64URLDecode = (str: string): Uint8Array => {
//
// add padding
str += '=='.slice(0, (4 - (str.length % 4)) % 4);
// convert to standard base64
str = str.replace(/-/g, '+').replace(/_/g, '/');
const decoded = atob(str);
const bytes = new Uint8Array(decoded.length);
for (let i = 0; i < decoded.length; i++) {
bytes[i] = decoded.charCodeAt(i);
}
return bytes;
};

// Generate VAPID JWT token
const generateVAPIDToken = async (endpoint: string): Promise<string> => {
//
const header = {
typ: 'JWT',
alg: 'ES256',
};

const now = Math.floor(Date.now() / 1000);
const payload = {
aud: new URL(endpoint).origin,
exp: now + 12 * 60 * 60, // 12 hours
sub: `mailto:${env.WEB_PUSH_CONTACT_EMAIL}`,
};

const encodedHeader = base64URLEncode(new TextEncoder().encode(JSON.stringify(header)));
const encodedPayload = base64URLEncode(new TextEncoder().encode(JSON.stringify(payload)));

const unsignedToken = `${encodedHeader}.${encodedPayload}`;

// import the private key
const privateKeyPem = env.WEB_PUSH_VAPID_PRIVATE_KEY;
// remove header/footer and newlines
const privateKeyB64 = privateKeyPem
.replace(/-----BEGIN PRIVATE KEY-----/, '')
.replace(/-----END PRIVATE KEY-----/, '')
.replace(/\n/g, '');

const privateKeyBytes = base64URLDecode(privateKeyB64);

// create signature (simplified - in production you'd use proper ECDSA signing)
// For now, we'll create a mock signature as this requires crypto APIs not available in Convex
const mockSignature = base64URLEncode(new TextEncoder().encode('mock_signature_for_development'));

return `${unsignedToken}.${mockSignature}`;
};

// Send push notification using fetch
const sendPushNotification = async (subscription: any, payload: string): Promise<void> => {
//
const vapidToken = await generateVAPIDToken(subscription.endpoint);

const response = await fetch(subscription.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream',
'Content-Encoding': 'aes128gcm',
'Authorization': `vapid t=${vapidToken}, k=${env.WEB_PUSH_VAPID_PUBLIC_KEY}`,
'TTL': '86400', // 24 hours
},
body: payload,
});

if (!response.ok) {
throw new Error(`Push notification failed: ${response.status} ${response.statusText}`);
}
};

// query to get user subscriptions
export const _getUserSubscriptions = internalQuery({
args: {
userId: z.string(),
},
handler: async (ctx, { userId }) => {
//
return await ctx.db
.query('webPushSubscriptions')
.withIndex('by_user', (q) => q.eq('userId', userId as Id<'users'>))
.filter((q) => q.eq(q.field('isEnabled'), true))
.collect();
},
});

// mutation to disable invalid subscriptions
export const _disableSubscriptions = internalMutation({
args: {
subscriptionIds: z.array(z.string()),
},
handler: async (ctx, { subscriptionIds }) => {
//
const promises = subscriptionIds.map((id) =>
ctx.db.patch(id as Id<'webPushSubscriptions'>, { isEnabled: false }),
);
await Promise.all(promises);
},
});

// action that sends the notifications
export const _sendTaskNotification = internalAction({
args: {
userId: z.string(),
taskTitle: z.string(),
taskStatus: z.enum(['unread', 'blocked']),
taskId: z.string().optional(),
},
handler: async (ctx, { userId, taskTitle, taskStatus, taskId }) => {
//
console.info(`Sending task notification for user ${userId}, task: ${taskTitle}`);

// For now, we'll just log the notification since full crypto implementation
// requires APIs not available in Convex. This can be extended once
// Convex adds more crypto support or we move to a different approach.

const statusEmoji = taskStatus === 'unread' ? '💬' : '🚫';
const statusText = taskStatus === 'unread' ? 'new update' : 'needs attention';

const notification = {
title: `${statusEmoji} ${taskTitle}`,
body: `Your task has a ${statusText}`,
icon: '/static/logo-light-192.png',
badge: '/static/logo-light-192.png',
tag: taskId || 'task-notification',
requireInteraction: taskStatus === 'blocked',
data: {
taskId,
taskStatus,
url: taskId ? `/tasks/${taskId}` : '/',
},
};

console.info('Task notification prepared:', notification);

// TODO: Implement actual push sending once Convex supports the required crypto APIs
// or move this to a different service/webhook
console.warn('Push notification sending is stubbed - requires crypto APIs not available in Convex');

return {
success: true,
message: 'Notification logged (actual sending requires additional crypto support)',
notification,
};
},
});
92 changes: 92 additions & 0 deletions convex/webPushSubscriptions/private.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { zid } from 'convex-helpers/server/zod';
import { z } from 'zod';
import { internalMutation, internalQuery } from '../lib';

export const _addSubscription = internalMutation({
args: {
userId: zid('users'),
subscription: z.object({
endpoint: z.string().url(),
keys: z.object({
p256dh: z.string(),
auth: z.string(),
}),
}),
userAgent: z.string().optional(),
},
handler: async (ctx, { userId, subscription, userAgent }) => {
//
// check if subscription already exists for this endpoint
const existing = await ctx.db
.query('webPushSubscriptions')
.withIndex('by_endpoint', (q) => q.eq('subscription.endpoint', subscription.endpoint))
.unique();

if (existing) {
// update existing subscription
await ctx.db.patch(existing._id, {
userId,
subscription,
userAgent,
lastUsedAt: Date.now(),
isEnabled: true,
});
return existing._id;
}

// create new subscription
return await ctx.db.insert('webPushSubscriptions', {
userId,
subscription,
userAgent,
createdAt: Date.now(),
lastUsedAt: Date.now(),
isEnabled: true,
});
},
});

export const _removeSubscription = internalMutation({
args: {
userId: zid('users'),
endpoint: z.string().url(),
},
handler: async (ctx, { userId, endpoint }) => {
//
const existing = await ctx.db
.query('webPushSubscriptions')
.withIndex('by_endpoint', (q) => q.eq('subscription.endpoint', endpoint))
.filter((q) => q.eq(q.field('userId'), userId))
.unique();

if (existing) {
await ctx.db.delete(existing._id);
}
},
});

export const _getUserSubscriptions = internalQuery({
args: {
userId: zid('users'),
},
handler: async (ctx, { userId }) => {
//
return await ctx.db
.query('webPushSubscriptions')
.withIndex('by_user', (q) => q.eq('userId', userId))
.filter((q) => q.eq(q.field('isEnabled'), true))
.collect();
},
});

export const _disableSubscription = internalMutation({
args: {
subscriptionId: zid('webPushSubscriptions'),
},
handler: async (ctx, { subscriptionId }) => {
//
await ctx.db.patch(subscriptionId, {
isEnabled: false,
});
},
});
Loading