Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ddbc99d
feat: implement service worker registration and notification handling…
IamKirbki Mar 18, 2026
b766aeb
Fix: added the integrations_count key to the Project object because t…
IamKirbki Mar 18, 2026
19d90fd
feat: enhance device management by adding device credentials and upda…
IamKirbki Mar 19, 2026
52a6da0
feat: add VAPID key management for push notifications with database i…
IamKirbki Mar 20, 2026
74cde73
feat: add endpoint to retrieve VAPID public key for push notifications
IamKirbki Mar 22, 2026
fc8a946
feat(webpush): add Web Push provider implementation
IamKirbki Mar 23, 2026
cd1200b
feat(rbac): add device resource with appropriate permissions
IamKirbki Mar 24, 2026
6f59dfb
Merge branch 'main' of github.com:lunogram/platform into feat/push-no…
IamKirbki Mar 24, 2026
45015cf
feat: add DeviceRegistration interface for push notifications
IamKirbki Mar 24, 2026
e338a44
refactor(webpush): reorganize code structure and improve type definit…
IamKirbki Mar 24, 2026
a673132
feat(webpush): auto-inject VAPID keys for webpush providers and clean…
IamKirbki Mar 24, 2026
ac261c8
feat(devices): enhance device registration to support FCM token and o…
IamKirbki Mar 30, 2026
acd3f9a
feat(webpush): enhance APNs support and add file upload options for F…
IamKirbki Apr 2, 2026
15a7ef7
feat(push-notifications): implement unified push configuration for FC…
IamKirbki Apr 8, 2026
0442dc0
Merge branch 'main' of github.com:lunogram/platform into feat/push-no…
IamKirbki Apr 8, 2026
d274548
Refactor device registration API and related components
IamKirbki Apr 8, 2026
b720773
Refactor(Webpush): split the notifications into multiple providers
IamKirbki Apr 8, 2026
b10f2a1
Hotifx: trying to fix the request
IamKirbki Apr 9, 2026
6a0818a
feat: move push provider management to project level
jeroenrinzema Apr 9, 2026
685abd7
feat(devices): enhance device registration with user and project ID f…
IamKirbki Apr 9, 2026
09e0f5b
fix: removed testing web push service worker
jeroenrinzema Apr 9, 2026
4b8ffc1
refactor(push-notifications): move device and VAPID endpoints to client
jeroenrinzema Apr 9, 2026
b98ea08
Merge branch 'feat/push-project-management' into feat/push-notifications
jeroenrinzema Apr 9, 2026
1d3a423
refactor: rename devices table, unify config, add data field
jeroenrinzema Apr 10, 2026
323c188
fix(push): address PR review feedback
jeroenrinzema Apr 10, 2026
2bb898a
fix: include wasmcrypto package tinygo-org/tinygo#5291
jeroenrinzema Apr 10, 2026
a295abd
fix(push): use wasmexport and restore stdlib crypto
jeroenrinzema Apr 11, 2026
23d3bf4
refactor: node cleanup and improve context handling
jeroenrinzema Apr 11, 2026
9582428
Merge remote-tracking branch 'origin/main' into feat/push-notifications
jeroenrinzema Apr 11, 2026
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
2 changes: 1 addition & 1 deletion cmd/lunogram/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ func run() error {
logger.Info("initializing cluster")

sched := scheduler.NewController(ctx, logger, conf, journeyStore, usersStore, managementStore, pub)
lead := leader.NewHandler(sched)
lead := leader.NewHandler(sched, managementStore, logger)
cons, err := consensus.NewCluster(ctx, logger, conf)
if err != nil {
return err
Expand Down
36 changes: 36 additions & 0 deletions console/public/sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
self.addEventListener('push', function(event) {
if (!event.data) return;

try {
const data = event.data.json();
const title = data.title || 'Notification';
const options = {
body: data.body,
icon: data.icon,
badge: data.badge,
image: data.image,
data: data.data || {}
};

event.waitUntil(
self.registration.showNotification(title, options)
);
} catch (e) {
// If not JSON, show simple text
event.waitUntil(
self.registration.showNotification('Notification', {
body: event.data.text()
})
);
}
});

self.addEventListener('notificationclick', function(event) {
event.notification.close();
// We can handle click events here, like opening a specific URL
if (event.notification.data && event.notification.data.url) {
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
}
});
49 changes: 49 additions & 0 deletions console/src/components/schema-fields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,24 @@ export interface Schema {
format?: string
order?: number
preview?: string
fileUpload?: boolean
fileAccept?: string
}

function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
if (typeof reader.result !== "string") {
reject(new Error("failed to read file as base64"))
return
}
const comma = reader.result.indexOf(",")
resolve(comma >= 0 ? reader.result.slice(comma + 1) : reader.result)
}
reader.onerror = () => reject(reader.error ?? new Error("failed to read file"))
reader.readAsDataURL(file)
})
}

/**
Expand Down Expand Up @@ -109,6 +127,37 @@ export function SchemaFields({
const required = schema.required?.includes(key)
const fieldTitle = item.title ?? snakeToTitle(key)

if (item.type === "string" && item.fileUpload) {
return (
<div key={key} className="grid gap-1.5">
<Label className="inline-flex items-center gap-1 text-sm font-medium">
{fieldTitle}
{required && <span className="text-destructive">*</span>}
</Label>
{item.description && (
<p className="text-sm text-muted-foreground">{item.description}</p>
)}
<Input
type="file"
accept={item.fileAccept}
onChange={async (e) => {
const file = e.target.files?.[0]
if (!file) return
try {
const base64 = await fileToBase64(file)
set(key, base64)
} catch (error) {
console.error(error)
}
}}
/>
{value[key] && (
<p className="text-xs text-muted-foreground">File configured</p>
)}
</div>
)
}

// format: "code"
if (item.format === "code") {
return (
Expand Down
106 changes: 95 additions & 11 deletions console/src/components/sender-identity-combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,21 @@ import {
} from "@/components/ui/command"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import oapiClient, { type SenderIdentity } from "@/oapi/client"
import type { components } from "@/oapi/management.generated"

type Provider = components["schemas"]["Provider"]

interface SenderIdentityComboboxProps {
projectId: string
channel: "email" | "sms"
providerId?: string
value?: string
onChange: (value: string) => void
onIdentitySelect?: (identity: SenderIdentity) => void
Expand All @@ -35,7 +44,6 @@ type PopoverView = "list" | "create"
export function SenderIdentityCombobox({
projectId,
channel,
providerId,
value,
onChange,
onIdentitySelect,
Expand All @@ -55,6 +63,9 @@ export function SenderIdentityCombobox({
const [view, setView] = React.useState<PopoverView>("list")
const [newAddress, setNewAddress] = React.useState("")
const [newName, setNewName] = React.useState("")
const [newProviderId, setNewProviderId] = React.useState("")
const [providers, setProviders] = React.useState<Provider[]>([])
const [providersLoading, setProvidersLoading] = React.useState(false)
const [creating, setCreating] = React.useState(false)
const [createError, setCreateError] = React.useState<string | null>(null)

Expand Down Expand Up @@ -87,7 +98,7 @@ export function SenderIdentityCombobox({
return (resolvedIdentity.traits?.address as string) ?? ""
}, [resolvedIdentity, value])

// Fetch identities
// Fetch all identities for the project+channel (no provider filter)
const fetchIdentities = React.useCallback(async () => {
if (fetchedRef.current) return
setLoading(true)
Expand All @@ -97,7 +108,7 @@ export function SenderIdentityCombobox({
{
params: {
path: { projectID: projectId },
query: { provider_id: providerId, channel },
query: { channel },
},
},
)
Expand All @@ -109,12 +120,33 @@ export function SenderIdentityCombobox({
} finally {
setLoading(false)
}
}, [projectId, providerId, channel])
}, [projectId, channel])

// Fetch providers for the create view
const fetchProviders = React.useCallback(async () => {
setProvidersLoading(true)
try {
const { data } = await oapiClient.GET("/api/admin/projects/{projectID}/providers", {
params: {
path: { projectID: projectId },
},
})
const allProviders = data?.results ?? []
const filtered = allProviders.filter((p) => p.channels?.includes(channel))
setProviders(filtered)
// Auto-select the first provider if only one exists
if (filtered.length === 1) {
setNewProviderId(filtered[0].id)
}
} finally {
setProvidersLoading(false)
}
}, [projectId, channel])

// Refetch when filters change
React.useEffect(() => {
fetchedRef.current = false
}, [projectId, providerId, channel])
}, [projectId, channel])

// Fetch on mount when a value is already set so the display resolves
React.useEffect(() => {
Expand Down Expand Up @@ -155,13 +187,15 @@ export function SenderIdentityCombobox({
const resetCreateForm = () => {
setNewAddress("")
setNewName("")
setNewProviderId("")
setCreateError(null)
setCreating(false)
}

const handleSwitchToCreate = () => {
resetCreateForm()
setView("create")
fetchProviders()
}

const handleCancelCreate = () => {
Expand All @@ -171,7 +205,7 @@ export function SenderIdentityCombobox({

const handleCreate = async () => {
const address = newAddress.trim()
if (!providerId || !address) return
if (!newProviderId || !address) return

setCreating(true)
setCreateError(null)
Expand All @@ -185,7 +219,7 @@ export function SenderIdentityCombobox({
"/api/admin/projects/{projectID}/sender-identities",
{
params: { path: { projectID: projectId } },
body: { provider_id: providerId, channel, traits },
body: { provider_id: newProviderId, channel, traits },
},
)
if (error || !newIdentity) {
Expand Down Expand Up @@ -267,6 +301,10 @@ export function SenderIdentityCombobox({
onAddressChange={setNewAddress}
name={newName}
onNameChange={setNewName}
providerId={newProviderId}
onProviderChange={setNewProviderId}
providers={providers}
providersLoading={providersLoading}
onSave={handleCreate}
onCancel={handleCancelCreate}
creating={creating}
Expand Down Expand Up @@ -412,6 +450,10 @@ interface CreateViewProps {
onAddressChange: (value: string) => void
name: string
onNameChange: (value: string) => void
providerId: string
onProviderChange: (value: string) => void
providers: Provider[]
providersLoading: boolean
onSave: () => void
onCancel: () => void
creating: boolean
Expand All @@ -425,6 +467,10 @@ function CreateView({
onAddressChange,
name,
onNameChange,
providerId,
onProviderChange,
providers,
providersLoading,
onSave,
onCancel,
creating,
Expand All @@ -436,7 +482,7 @@ function CreateView({
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault()
if (address.trim()) handleSave()
if (address.trim() && providerId) handleSave()
}
if (e.key === "Escape") {
e.preventDefault()
Expand All @@ -445,7 +491,7 @@ function CreateView({
}

const handleSave = () => {
if (!address.trim() || creating) return
if (!address.trim() || !providerId || creating) return
onSave()
}

Expand All @@ -467,6 +513,44 @@ function CreateView({
</div>

<div className="space-y-3">
<div className="space-y-1">
<label
htmlFor="new-sender-provider"
className="text-xs font-medium text-foreground"
>
{t("integration", "Integration")}
</label>
{providersLoading ? (
<div className="flex items-center gap-2 h-8 px-3 text-sm text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
{t("loading", "Loading...")}
</div>
) : (
<Select value={providerId} onValueChange={onProviderChange}>
<SelectTrigger className="h-8 w-full">
<SelectValue
placeholder={t("select_integration", "Select integration...")}
/>
</SelectTrigger>
<SelectContent>
{providers.length === 0 ? (
<div className="py-2 px-2 text-sm text-muted-foreground">
{t(
"no_integrations_found",
"No integrations found for this channel.",
)}
</div>
) : (
providers.map((provider) => (
<SelectItem key={provider.id} value={provider.id}>
{provider.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
)}
</div>
{isEmail && (
<div className="space-y-1">
<label
Expand Down Expand Up @@ -516,7 +600,7 @@ function CreateView({
type="button"
size="sm"
onClick={handleSave}
disabled={!address.trim() || creating}
disabled={!address.trim() || !providerId || creating}
className="h-8"
>
{creating && <Loader2 className="h-3 w-3 animate-spin mr-1.5" />}
Expand Down
10 changes: 8 additions & 2 deletions console/src/components/ui/attribute-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,10 @@ function TreeRow({
<div className="inline-flex items-center">
{/* Type Selector */}
<Select value={node.type} onValueChange={handleTypeChange}>
<SelectTrigger className="h-8 w-[80px] px-2 text-xs font-medium rounded-r-none bg-muted/50 focus:z-10 shadow-none">
<SelectTrigger
elevation="flat"
className="h-8 w-[80px] px-2 text-xs font-medium rounded-r-none bg-muted/50 focus:z-10"
>
<SelectValue>{TYPE_LABELS[node.type]}</SelectValue>
</SelectTrigger>
<SelectContent>
Expand Down Expand Up @@ -240,7 +243,10 @@ function TreeRow({
value={node.value}
onValueChange={(value) => handleUpdate({ value })}
>
<SelectTrigger className="h-8 w-20 rounded-none border-l-0 text-sm focus:z-10 -ml-px shadow-none">
<SelectTrigger
elevation="flat"
className="h-8 w-20 rounded-none border-l-0 text-sm focus:z-10 -ml-px"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
Expand Down
Loading
Loading