Merged
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
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,9 +48,7 @@ export async function sendNotification(request: SendNotificationRequest): Promis
}
}

export async function getDevicesWithTokens(): Promise<
{ token: string; platform: string; eName: string }[]
> {
export async function getDevicesWithTokens(): Promise<{ token: string; eName: string }[]> {
const { env } = await import('$env/dynamic/private');
const provisionerUrl =
env.PUBLIC_PROVISIONER_URL || env.PROVISIONER_URL || 'http://localhost:3001';
Expand All@@ -69,7 +67,7 @@ export async function getDevicesWithTokens(): Promise<

export async function getDevicesByEName(
eName: string
): Promise<{ token: string; platform: string; eName: string }[]> {
): Promise<{ token: string; eName: string }[]> {
const { env } = await import('$env/dynamic/private');
const provisionerUrl =
env.PUBLIC_PROVISIONER_URL || env.PROVISIONER_URL || 'http://localhost:3001';
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@ export interface DeviceRegistration {
eName: string;
deviceId: string;
platform: "android" | "ios" | "desktop";
fcmToken?: string; // For Android/iOS push notifications
pushToken?: string;
registrationTime: Date;
}

Expand DownExpand Up@@ -76,17 +76,16 @@ class NotificationService {
throw new Error("Notification permissions not granted");
}

// Get FCM token for mobile platforms
let fcmToken: string | undefined;
let pushToken: string | undefined;
if (platform === "android" || platform === "ios") {
fcmToken = await this.getFCMToken();
pushToken = await this.getPushNotificationToken();
}

const registration: DeviceRegistration = {
eName,
deviceId,
platform,
fcmToken,
pushToken,
registrationTime: new Date(),
};

Expand DownExpand Up@@ -173,6 +172,7 @@ class NotificationService {
body: JSON.stringify({
eName: this.deviceRegistration.eName,
deviceId: this.deviceRegistration.deviceId,
pushToken: this.deviceRegistration.pushToken,
}),
},
);
Expand DownExpand Up@@ -342,9 +342,9 @@ class NotificationService {
}

/**
* Get push notification token (FCM on Android, APNs on iOS)
* Get push notification token from the platform (FCM on Android, APNs on iOS).
*/
private async getFCMToken(): Promise<string | undefined> {
private async getPushNotificationToken(): Promise<string | undefined> {
try {
return await registerForPushNotifications();
} catch (error) {
Expand All@@ -354,15 +354,15 @@ class NotificationService {
}

/**
* Request permissions and get push notification token (FCM on Android, APNs on iOS).
* Request permissions and get push notification token.
* Returns undefined on desktop or if permission is denied.
*/
async getPushToken(): Promise<string | undefined> {
const hasPermission = await this.requestPermissions();
if (!hasPermission) return undefined;
const platform = await this.getPlatform();
if (platform !== "android" && platform !== "ios") return undefined;
return this.getFCMToken();
return this.getPushNotificationToken();
}
/**
* Get eName from vault (helper method)
Expand Down
28 changes: 24 additions & 4 deletions infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte
Original file line numberDiff line numberDiff line change
Expand Up@@ -96,12 +96,22 @@ onMount(async () => {
);
} catch (error) {
console.error("Error syncing public key:", error);
// Continue to app even if sync fails - non-blocking
}

// Register device for push notifications on login
try {
await globalState.notificationService.registerDevice(
vault.ename,
);
} catch (error) {
console.error(
"Error registering device for notifications:",
error,
);
}
}
} catch (error) {
console.error("Error during eVault health check:", error);
// Continue to app even if health check fails - non-blocking
}

// Check if there's a pending deep link to process
Expand DownExpand Up@@ -176,12 +186,22 @@ onMount(async () => {
);
} catch (error) {
console.error("Error syncing public key:", error);
// Continue to app even if sync fails - non-blocking
}

// Register device for push notifications on biometric login
try {
await globalState.notificationService.registerDevice(
vault.ename,
);
} catch (error) {
console.error(
"Error registering device for notifications:",
error,
);
}
}
} catch (error) {
console.error("Error during eVault health check:", error);
// Continue to app even if health check fails - non-blocking
}

// Check if there's a pending deep link to process
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,7 +65,7 @@ export class NotificationController {

private async registerDevice(req: Request, res: Response) {
try {
const { eName, deviceId, platform, fcmToken } = req.body;
const { eName, deviceId, platform, pushToken } = req.body;

if (!eName || !deviceId || !platform) {
return res.status(400).json({
Expand All@@ -74,20 +74,17 @@ export class NotificationController {
});
}

if (fcmToken && typeof fcmToken === "string" && fcmToken.trim()) {
await this.deviceTokenService.register({
eName,
deviceId,
platform,
token: fcmToken.trim(),
});
const token = typeof pushToken === "string" ? pushToken.trim() : undefined;

if (token) {
await this.deviceTokenService.register(eName, token);
}

const verification = await this.notificationService.registerDevice({
eName,
deviceId,
platform,
fcmToken: fcmToken.trim(),
pushToken: token,
registrationTime: new Date(),
});

Expand All@@ -107,7 +104,7 @@ export class NotificationController {

private async unregisterDevice(req: Request, res: Response) {
try {
const { eName, deviceId } = req.body;
const { eName, deviceId, pushToken } = req.body;

if (!eName || !deviceId) {
return res.status(400).json({
Expand All@@ -116,7 +113,11 @@ export class NotificationController {
});
}

await this.deviceTokenService.unregister(eName, deviceId);
const token = typeof pushToken === "string" ? pushToken.trim() : undefined;
if (token) {
await this.deviceTokenService.unregister(eName, token);
}

const success = await this.notificationService.unregisterDevice(eName, deviceId);

res.json({
Expand Down
12 changes: 3 additions & 9 deletions infrastructure/evault-core/src/entities/DeviceToken.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,22 +8,16 @@ import {
} from "typeorm";

@Entity("device_token")
@Index(["eName", "deviceId"], { unique: true })
@Index(["eName"], { unique: true })
export class DeviceToken {
@PrimaryGeneratedColumn("uuid")
id!: string;

@Column({ type: "varchar" })
eName!: string;

@Column({ type: "varchar" })
token!: string;

@Column({ type: "varchar" })
platform!: string;

@Column({ type: "varchar" })
deviceId!: string;
@Column({ type: "text", array: true, default: "{}" })
tokens!: string[];

@CreateDateColumn()
createdAt!: Date;
Expand Down
4 changes: 2 additions & 2 deletions infrastructure/evault-core/src/entities/Verification.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,8 +44,8 @@ export class Verification {
@Column({ type: "varchar", nullable: true })
platform!: string;

@Column({ type: "varchar", nullable: true })
fcmToken!: string;
@Column({ type: "text", array: true, default: "{}", nullable: true })
pushTokens!: string[];

@Column({ type: "boolean", default: true })
deviceActive!: boolean;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class Migration1773400000000 implements MigrationInterface {
name = "Migration1773400000000";

public async up(queryRunner: QueryRunner): Promise<void> {
// 1. Restructure device_token: collapse multi-row-per-eName into one-row-per-eName with tokens array

await queryRunner.query(`
CREATE TABLE "device_token_new" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"eName" character varying NOT NULL,
"tokens" text[] NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_device_token_new" PRIMARY KEY ("id")
)
`);

await queryRunner.query(`
INSERT INTO "device_token_new" ("eName", "tokens", "createdAt", "updatedAt")
SELECT
"eName",
array_agg(DISTINCT "token"),
MIN("createdAt"),
MAX("updatedAt")
FROM "device_token"
GROUP BY "eName"
`);

await queryRunner.query(`DROP TABLE "device_token"`);
await queryRunner.query(`ALTER TABLE "device_token_new" RENAME TO "device_token"`);
await queryRunner.query(`ALTER TABLE "device_token" RENAME CONSTRAINT "PK_device_token_new" TO "PK_device_token"`);
await queryRunner.query(`CREATE UNIQUE INDEX "UQ_device_token_ename" ON "device_token" ("eName")`);

// 2. Rename verification.fcmToken -> pushTokens (varchar -> text[])

await queryRunner.query(`ALTER TABLE "verification" ADD "pushTokens" text[] DEFAULT '{}'`);
await queryRunner.query(`
UPDATE "verification"
SET "pushTokens" = CASE
WHEN "fcmToken" IS NOT NULL AND "fcmToken" != '' THEN ARRAY["fcmToken"]
ELSE '{}'
END
`);
await queryRunner.query(`ALTER TABLE "verification" DROP COLUMN "fcmToken"`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
// Revert verification: pushTokens -> fcmToken
await queryRunner.query(`ALTER TABLE "verification" ADD "fcmToken" character varying`);
await queryRunner.query(`
UPDATE "verification"
SET "fcmToken" = CASE
WHEN "pushTokens" IS NOT NULL AND array_length("pushTokens", 1) > 0 THEN "pushTokens"[1]
ELSE NULL
END
`);
await queryRunner.query(`ALTER TABLE "verification" DROP COLUMN "pushTokens"`);

// Revert device_token: expand array rows back into individual rows
await queryRunner.query(`DROP INDEX "UQ_device_token_ename"`);

await queryRunner.query(`
CREATE TABLE "device_token_old" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"eName" character varying NOT NULL,
"token" character varying NOT NULL,
"platform" character varying NOT NULL DEFAULT '',
"deviceId" character varying NOT NULL DEFAULT '',
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_device_token_old" PRIMARY KEY ("id")
)
`);

await queryRunner.query(`
INSERT INTO "device_token_old" ("eName", "token", "createdAt", "updatedAt")
SELECT "eName", unnest("tokens"), "createdAt", "updatedAt"
FROM "device_token"
`);

await queryRunner.query(`DROP TABLE "device_token"`);
await queryRunner.query(`ALTER TABLE "device_token_old" RENAME TO "device_token"`);
await queryRunner.query(`ALTER TABLE "device_token" RENAME CONSTRAINT "PK_device_token_old" TO "PK_device_token"`);
await queryRunner.query(`CREATE UNIQUE INDEX "UQ_device_token_ename_deviceid" ON "device_token" ("eName", "deviceId")`);
await queryRunner.query(`CREATE INDEX "IDX_device_token_ename" ON "device_token" ("eName")`);
Comment on lines +77 to +87

@coderabbitaicoderabbitaiBotMar 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Down migration will fail with unique constraint violation.

When unnesting the tokens array back to individual rows, all rows get deviceId = '' (empty string). If any eName had multiple tokens, creating the unique index UQ_device_token_ename_deviceid on ("eName", "deviceId") will fail because multiple rows will have identical (eName, '') tuples.

Additionally, platform data was lost in the up migration, so all restored rows will have platform = '', which may break application logic expecting valid platform values.

🛠️ Suggested approach

Consider one of these options:

  1. Skip restoring the unique index in down migration if exact rollback fidelity isn't required:
- await queryRunner.query(`CREATE UNIQUE INDEX "UQ_device_token_ename_deviceid" ON "device_token" ("eName", "deviceId")`);+ // Note: Cannot restore original unique constraint as deviceId data was lost in up migration+ await queryRunner.query(`CREATE INDEX "IDX_device_token_ename_deviceid" ON "device_token" ("eName", "deviceId")`);
  1. Preserve platform/deviceId in up migration by storing them in a separate structure or JSONB column if rollback fidelity is important.

  2. Document that this migration is not safely reversible if the data loss is acceptable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@infrastructure/evault-core/src/migrations/1773400000000-MultiDeviceTokens.ts`
around lines 77 - 87, The down migration currently unnests tokens into
"device_token_old" but sets deviceId and platform to empty strings, so creating
the unique index UQ_device_token_ename_deviceid on ("eName","deviceId") will
fail when an eName has multiple rows; to fix, modify the down migration logic
that runs after the INSERT/RENAME (references: the INSERT INTO
"device_token_old" SELECT ... FROM "device_token", the RENAME TO "device_token",
and the CREATE UNIQUE INDEX "UQ_device_token_ename_deviceid") to not create the
unique index (remove or guard the CREATE UNIQUE INDEX
"UQ_device_token_ename_deviceid" statement) OR else preserve real
deviceId/platform during the up migration so they can be restored here; pick one
approach and update the migration accordingly and add a comment that the down
migration is not fully reversible if you choose to skip the index.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we only move forward, shinzou wo sasageyo

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sosweetham — Understood, forward only! 🫡 Acknowledged and noted for future reviews — I won't flag down migration reversibility issues for this project.

(∩ᵒ ᵕ ᵒ∩)
shinzou wo sasageyo


✏️ Learnings added
Learnt from: sosweetham
Repo: MetaState-Prototype-Project/prototype PR: 913
File: infrastructure/evault-core/src/migrations/1773400000000-MultiDeviceTokens.ts:77-87
Timestamp: 2026-03-12T11:03:53.941Z
Learning: The MetaState-Prototype-Project team follows a forward-only migration philosophy. Down migration reversibility is intentionally not a requirement. Do not flag issues about down migrations lacking full rollback fidelity (e.g., lost columns, unrestorable unique indexes) in this repository.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: coodos
Repo: MetaState-Prototype-Project/prototype PR: 691
File: platforms/dreamsync-api/src/database/migrations/1768904445609-migration.ts:6-11
Timestamp: 2026-01-20T10:41:46.622Z
Learning: Guideline: For Wishlist-like data, treat derived fields (e.g., summaryWants, summaryOffers) as non-persistent and regenerable from the canonical content. Ensure migrations and models do not rely on persisting these derived fields; only the core wishlist content should be stored and preserved. In reviews, verify that code paths and DB schemas do not mandate upgrading or snapshotting derived fields, and that any recomputation logic is idempotent and safe to run on startup or migration.

}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
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
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,9 +48,7 @@ export async function sendNotification(request: SendNotificationRequest): Promis
}
}

export async function getDevicesWithTokens(): Promise<
{ token: string; platform: string; eName: string }[]
> {
export async function getDevicesWithTokens(): Promise<{ token: string; eName: string }[]> {
const { env } = await import('$env/dynamic/private');
const provisionerUrl =
env.PUBLIC_PROVISIONER_URL || env.PROVISIONER_URL || 'http://localhost:3001';
Expand All@@ -69,7 +67,7 @@ export async function getDevicesWithTokens(): Promise<

export async function getDevicesByEName(
eName: string
): Promise<{ token: string; platform: string; eName: string }[]> {
): Promise<{ token: string; eName: string }[]> {
const { env } = await import('$env/dynamic/private');
const provisionerUrl =
env.PUBLIC_PROVISIONER_URL || env.PROVISIONER_URL || 'http://localhost:3001';
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@ export interface DeviceRegistration {
eName: string;
deviceId: string;
platform: "android" | "ios" | "desktop";
fcmToken?: string; // For Android/iOS push notifications
pushToken?: string;
registrationTime: Date;
}

Expand DownExpand Up@@ -76,17 +76,16 @@ class NotificationService {
throw new Error("Notification permissions not granted");
}

// Get FCM token for mobile platforms
let fcmToken: string | undefined;
let pushToken: string | undefined;
if (platform === "android" || platform === "ios") {
fcmToken = await this.getFCMToken();
pushToken = await this.getPushNotificationToken();
}

const registration: DeviceRegistration = {
eName,
deviceId,
platform,
fcmToken,
pushToken,
registrationTime: new Date(),
};

Expand DownExpand Up@@ -173,6 +172,7 @@ class NotificationService {
body: JSON.stringify({
eName: this.deviceRegistration.eName,
deviceId: this.deviceRegistration.deviceId,
pushToken: this.deviceRegistration.pushToken,
}),
},
);
Expand DownExpand Up@@ -342,9 +342,9 @@ class NotificationService {
}

/**
* Get push notification token (FCM on Android, APNs on iOS)
* Get push notification token from the platform (FCM on Android, APNs on iOS).
*/
private async getFCMToken(): Promise<string | undefined> {
private async getPushNotificationToken(): Promise<string | undefined> {
try {
return await registerForPushNotifications();
} catch (error) {
Expand All@@ -354,15 +354,15 @@ class NotificationService {
}

/**
* Request permissions and get push notification token (FCM on Android, APNs on iOS).
* Request permissions and get push notification token.
* Returns undefined on desktop or if permission is denied.
*/
async getPushToken(): Promise<string | undefined> {
const hasPermission = await this.requestPermissions();
if (!hasPermission) return undefined;
const platform = await this.getPlatform();
if (platform !== "android" && platform !== "ios") return undefined;
return this.getFCMToken();
return this.getPushNotificationToken();
}
/**
* Get eName from vault (helper method)
Expand Down
28 changes: 24 additions & 4 deletions infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte
Original file line numberDiff line numberDiff line change
Expand Up@@ -96,12 +96,22 @@ onMount(async () => {
);
} catch (error) {
console.error("Error syncing public key:", error);
// Continue to app even if sync fails - non-blocking
}

// Register device for push notifications on login
try {
await globalState.notificationService.registerDevice(
vault.ename,
);
} catch (error) {
console.error(
"Error registering device for notifications:",
error,
);
}
}
} catch (error) {
console.error("Error during eVault health check:", error);
// Continue to app even if health check fails - non-blocking
}

// Check if there's a pending deep link to process
Expand DownExpand Up@@ -176,12 +186,22 @@ onMount(async () => {
);
} catch (error) {
console.error("Error syncing public key:", error);
// Continue to app even if sync fails - non-blocking
}

// Register device for push notifications on biometric login
try {
await globalState.notificationService.registerDevice(
vault.ename,
);
} catch (error) {
console.error(
"Error registering device for notifications:",
error,
);
}
}
} catch (error) {
console.error("Error during eVault health check:", error);
// Continue to app even if health check fails - non-blocking
}

// Check if there's a pending deep link to process
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,7 +65,7 @@ export class NotificationController {

private async registerDevice(req: Request, res: Response) {
try {
const { eName, deviceId, platform, fcmToken } = req.body;
const { eName, deviceId, platform, pushToken } = req.body;

if (!eName || !deviceId || !platform) {
return res.status(400).json({
Expand All@@ -74,20 +74,17 @@ export class NotificationController {
});
}

if (fcmToken && typeof fcmToken === "string" && fcmToken.trim()) {
await this.deviceTokenService.register({
eName,
deviceId,
platform,
token: fcmToken.trim(),
});
const token = typeof pushToken === "string" ? pushToken.trim() : undefined;

if (token) {
await this.deviceTokenService.register(eName, token);
}

const verification = await this.notificationService.registerDevice({
eName,
deviceId,
platform,
fcmToken: fcmToken.trim(),
pushToken: token,
registrationTime: new Date(),
});

Expand All@@ -107,7 +104,7 @@ export class NotificationController {

private async unregisterDevice(req: Request, res: Response) {
try {
const { eName, deviceId } = req.body;
const { eName, deviceId, pushToken } = req.body;

if (!eName || !deviceId) {
return res.status(400).json({
Expand All@@ -116,7 +113,11 @@ export class NotificationController {
});
}

await this.deviceTokenService.unregister(eName, deviceId);
const token = typeof pushToken === "string" ? pushToken.trim() : undefined;
if (token) {
await this.deviceTokenService.unregister(eName, token);
}

const success = await this.notificationService.unregisterDevice(eName, deviceId);

res.json({
Expand Down
12 changes: 3 additions & 9 deletions infrastructure/evault-core/src/entities/DeviceToken.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,22 +8,16 @@ import {
} from "typeorm";

@Entity("device_token")
@Index(["eName", "deviceId"], { unique: true })
@Index(["eName"], { unique: true })
export class DeviceToken {
@PrimaryGeneratedColumn("uuid")
id!: string;

@Column({ type: "varchar" })
eName!: string;

@Column({ type: "varchar" })
token!: string;

@Column({ type: "varchar" })
platform!: string;

@Column({ type: "varchar" })
deviceId!: string;
@Column({ type: "text", array: true, default: "{}" })
tokens!: string[];

@CreateDateColumn()
createdAt!: Date;
Expand Down
4 changes: 2 additions & 2 deletions infrastructure/evault-core/src/entities/Verification.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,8 +44,8 @@ export class Verification {
@Column({ type: "varchar", nullable: true })
platform!: string;

@Column({ type: "varchar", nullable: true })
fcmToken!: string;
@Column({ type: "text", array: true, default: "{}", nullable: true })
pushTokens!: string[];

@Column({ type: "boolean", default: true })
deviceActive!: boolean;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class Migration1773400000000 implements MigrationInterface {
name = "Migration1773400000000";

public async up(queryRunner: QueryRunner): Promise<void> {
// 1. Restructure device_token: collapse multi-row-per-eName into one-row-per-eName with tokens array

await queryRunner.query(`
CREATE TABLE "device_token_new" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"eName" character varying NOT NULL,
"tokens" text[] NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_device_token_new" PRIMARY KEY ("id")
)
`);

await queryRunner.query(`
INSERT INTO "device_token_new" ("eName", "tokens", "createdAt", "updatedAt")
SELECT
"eName",
array_agg(DISTINCT "token"),
MIN("createdAt"),
MAX("updatedAt")
FROM "device_token"
GROUP BY "eName"
`);

await queryRunner.query(`DROP TABLE "device_token"`);
await queryRunner.query(`ALTER TABLE "device_token_new" RENAME TO "device_token"`);
await queryRunner.query(`ALTER TABLE "device_token" RENAME CONSTRAINT "PK_device_token_new" TO "PK_device_token"`);
await queryRunner.query(`CREATE UNIQUE INDEX "UQ_device_token_ename" ON "device_token" ("eName")`);

// 2. Rename verification.fcmToken -> pushTokens (varchar -> text[])

await queryRunner.query(`ALTER TABLE "verification" ADD "pushTokens" text[] DEFAULT '{}'`);
await queryRunner.query(`
UPDATE "verification"
SET "pushTokens" = CASE
WHEN "fcmToken" IS NOT NULL AND "fcmToken" != '' THEN ARRAY["fcmToken"]
ELSE '{}'
END
`);
await queryRunner.query(`ALTER TABLE "verification" DROP COLUMN "fcmToken"`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
// Revert verification: pushTokens -> fcmToken
await queryRunner.query(`ALTER TABLE "verification" ADD "fcmToken" character varying`);
await queryRunner.query(`
UPDATE "verification"
SET "fcmToken" = CASE
WHEN "pushTokens" IS NOT NULL AND array_length("pushTokens", 1) > 0 THEN "pushTokens"[1]
ELSE NULL
END
`);
await queryRunner.query(`ALTER TABLE "verification" DROP COLUMN "pushTokens"`);

// Revert device_token: expand array rows back into individual rows
await queryRunner.query(`DROP INDEX "UQ_device_token_ename"`);

await queryRunner.query(`
CREATE TABLE "device_token_old" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"eName" character varying NOT NULL,
"token" character varying NOT NULL,
"platform" character varying NOT NULL DEFAULT '',
"deviceId" character varying NOT NULL DEFAULT '',
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_device_token_old" PRIMARY KEY ("id")
)
`);

await queryRunner.query(`
INSERT INTO "device_token_old" ("eName", "token", "createdAt", "updatedAt")
SELECT "eName", unnest("tokens"), "createdAt", "updatedAt"
FROM "device_token"
`);

await queryRunner.query(`DROP TABLE "device_token"`);
await queryRunner.query(`ALTER TABLE "device_token_old" RENAME TO "device_token"`);
await queryRunner.query(`ALTER TABLE "device_token" RENAME CONSTRAINT "PK_device_token_old" TO "PK_device_token"`);
await queryRunner.query(`CREATE UNIQUE INDEX "UQ_device_token_ename_deviceid" ON "device_token" ("eName", "deviceId")`);
await queryRunner.query(`CREATE INDEX "IDX_device_token_ename" ON "device_token" ("eName")`);
Comment on lines +77 to +87

@coderabbitaicoderabbitaiBotMar 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Down migration will fail with unique constraint violation.

When unnesting the tokens array back to individual rows, all rows get deviceId = '' (empty string). If any eName had multiple tokens, creating the unique index UQ_device_token_ename_deviceid on ("eName", "deviceId") will fail because multiple rows will have identical (eName, '') tuples.

Additionally, platform data was lost in the up migration, so all restored rows will have platform = '', which may break application logic expecting valid platform values.

🛠️ Suggested approach

Consider one of these options:

  1. Skip restoring the unique index in down migration if exact rollback fidelity isn't required:
- await queryRunner.query(`CREATE UNIQUE INDEX "UQ_device_token_ename_deviceid" ON "device_token" ("eName", "deviceId")`);+ // Note: Cannot restore original unique constraint as deviceId data was lost in up migration+ await queryRunner.query(`CREATE INDEX "IDX_device_token_ename_deviceid" ON "device_token" ("eName", "deviceId")`);
  1. Preserve platform/deviceId in up migration by storing them in a separate structure or JSONB column if rollback fidelity is important.

  2. Document that this migration is not safely reversible if the data loss is acceptable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@infrastructure/evault-core/src/migrations/1773400000000-MultiDeviceTokens.ts`
around lines 77 - 87, The down migration currently unnests tokens into
"device_token_old" but sets deviceId and platform to empty strings, so creating
the unique index UQ_device_token_ename_deviceid on ("eName","deviceId") will
fail when an eName has multiple rows; to fix, modify the down migration logic
that runs after the INSERT/RENAME (references: the INSERT INTO
"device_token_old" SELECT ... FROM "device_token", the RENAME TO "device_token",
and the CREATE UNIQUE INDEX "UQ_device_token_ename_deviceid") to not create the
unique index (remove or guard the CREATE UNIQUE INDEX
"UQ_device_token_ename_deviceid" statement) OR else preserve real
deviceId/platform during the up migration so they can be restored here; pick one
approach and update the migration accordingly and add a comment that the down
migration is not fully reversible if you choose to skip the index.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we only move forward, shinzou wo sasageyo

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sosweetham — Understood, forward only! 🫡 Acknowledged and noted for future reviews — I won't flag down migration reversibility issues for this project.

(∩ᵒ ᵕ ᵒ∩)
shinzou wo sasageyo


✏️ Learnings added
Learnt from: sosweetham
Repo: MetaState-Prototype-Project/prototype PR: 913
File: infrastructure/evault-core/src/migrations/1773400000000-MultiDeviceTokens.ts:77-87
Timestamp: 2026-03-12T11:03:53.941Z
Learning: The MetaState-Prototype-Project team follows a forward-only migration philosophy. Down migration reversibility is intentionally not a requirement. Do not flag issues about down migrations lacking full rollback fidelity (e.g., lost columns, unrestorable unique indexes) in this repository.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: coodos
Repo: MetaState-Prototype-Project/prototype PR: 691
File: platforms/dreamsync-api/src/database/migrations/1768904445609-migration.ts:6-11
Timestamp: 2026-01-20T10:41:46.622Z
Learning: Guideline: For Wishlist-like data, treat derived fields (e.g., summaryWants, summaryOffers) as non-persistent and regenerable from the canonical content. Ensure migrations and models do not rely on persisting these derived fields; only the core wishlist content should be stored and preserved. In reviews, verify that code paths and DB schemas do not mandate upgrading or snapshotting derived fields, and that any recomputation logic is idempotent and safe to run on startup or migration.

}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,9 +48,7 @@ export async function sendNotification(request: SendNotificationRequest): Promis
}
}

export async function getDevicesWithTokens(): Promise<
{ token: string; platform: string; eName: string }[]
> {
export async function getDevicesWithTokens(): Promise<{ token: string; eName: string }[]> {
const { env } = await import('$env/dynamic/private');
const provisionerUrl =
env.PUBLIC_PROVISIONER_URL || env.PROVISIONER_URL || 'http://localhost:3001';
Expand All@@ -69,7 +67,7 @@ export async function getDevicesWithTokens(): Promise<

export async function getDevicesByEName(
eName: string
): Promise<{ token: string; platform: string; eName: string }[]> {
): Promise<{ token: string; eName: string }[]> {
const { env } = await import('$env/dynamic/private');
const provisionerUrl =
env.PUBLIC_PROVISIONER_URL || env.PROVISIONER_URL || 'http://localhost:3001';
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@ export interface DeviceRegistration {
eName: string;
deviceId: string;
platform: "android" | "ios" | "desktop";
fcmToken?: string; // For Android/iOS push notifications
pushToken?: string;
registrationTime: Date;
}

Expand DownExpand Up@@ -76,17 +76,16 @@ class NotificationService {
throw new Error("Notification permissions not granted");
}

// Get FCM token for mobile platforms
let fcmToken: string | undefined;
let pushToken: string | undefined;
if (platform === "android" || platform === "ios") {
fcmToken = await this.getFCMToken();
pushToken = await this.getPushNotificationToken();
}

const registration: DeviceRegistration = {
eName,
deviceId,
platform,
fcmToken,
pushToken,
registrationTime: new Date(),
};

Expand DownExpand Up@@ -173,6 +172,7 @@ class NotificationService {
body: JSON.stringify({
eName: this.deviceRegistration.eName,
deviceId: this.deviceRegistration.deviceId,
pushToken: this.deviceRegistration.pushToken,
}),
},
);
Expand DownExpand Up@@ -342,9 +342,9 @@ class NotificationService {
}

/**
* Get push notification token (FCM on Android, APNs on iOS)
* Get push notification token from the platform (FCM on Android, APNs on iOS).
*/
private async getFCMToken(): Promise<string | undefined> {
private async getPushNotificationToken(): Promise<string | undefined> {
try {
return await registerForPushNotifications();
} catch (error) {
Expand All@@ -354,15 +354,15 @@ class NotificationService {
}

/**
* Request permissions and get push notification token (FCM on Android, APNs on iOS).
* Request permissions and get push notification token.
* Returns undefined on desktop or if permission is denied.
*/
async getPushToken(): Promise<string | undefined> {
const hasPermission = await this.requestPermissions();
if (!hasPermission) return undefined;
const platform = await this.getPlatform();
if (platform !== "android" && platform !== "ios") return undefined;
return this.getFCMToken();
return this.getPushNotificationToken();
}
/**
* Get eName from vault (helper method)
Expand Down
28 changes: 24 additions & 4 deletions infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte
Original file line numberDiff line numberDiff line change
Expand Up@@ -96,12 +96,22 @@ onMount(async () => {
);
} catch (error) {
console.error("Error syncing public key:", error);
// Continue to app even if sync fails - non-blocking
}

// Register device for push notifications on login
try {
await globalState.notificationService.registerDevice(
vault.ename,
);
} catch (error) {
console.error(
"Error registering device for notifications:",
error,
);
}
}
} catch (error) {
console.error("Error during eVault health check:", error);
// Continue to app even if health check fails - non-blocking
}

// Check if there's a pending deep link to process
Expand DownExpand Up@@ -176,12 +186,22 @@ onMount(async () => {
);
} catch (error) {
console.error("Error syncing public key:", error);
// Continue to app even if sync fails - non-blocking
}

// Register device for push notifications on biometric login
try {
await globalState.notificationService.registerDevice(
vault.ename,
);
} catch (error) {
console.error(
"Error registering device for notifications:",
error,
);
}
}
} catch (error) {
console.error("Error during eVault health check:", error);
// Continue to app even if health check fails - non-blocking
}

// Check if there's a pending deep link to process
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,7 +65,7 @@ export class NotificationController {

private async registerDevice(req: Request, res: Response) {
try {
const { eName, deviceId, platform, fcmToken } = req.body;
const { eName, deviceId, platform, pushToken } = req.body;

if (!eName || !deviceId || !platform) {
return res.status(400).json({
Expand All@@ -74,20 +74,17 @@ export class NotificationController {
});
}

if (fcmToken && typeof fcmToken === "string" && fcmToken.trim()) {
await this.deviceTokenService.register({
eName,
deviceId,
platform,
token: fcmToken.trim(),
});
const token = typeof pushToken === "string" ? pushToken.trim() : undefined;

if (token) {
await this.deviceTokenService.register(eName, token);
}

const verification = await this.notificationService.registerDevice({
eName,
deviceId,
platform,
fcmToken: fcmToken.trim(),
pushToken: token,
registrationTime: new Date(),
});

Expand All@@ -107,7 +104,7 @@ export class NotificationController {

private async unregisterDevice(req: Request, res: Response) {
try {
const { eName, deviceId } = req.body;
const { eName, deviceId, pushToken } = req.body;

if (!eName || !deviceId) {
return res.status(400).json({
Expand All@@ -116,7 +113,11 @@ export class NotificationController {
});
}

await this.deviceTokenService.unregister(eName, deviceId);
const token = typeof pushToken === "string" ? pushToken.trim() : undefined;
if (token) {
await this.deviceTokenService.unregister(eName, token);
}

const success = await this.notificationService.unregisterDevice(eName, deviceId);

res.json({
Expand Down
12 changes: 3 additions & 9 deletions infrastructure/evault-core/src/entities/DeviceToken.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,22 +8,16 @@ import {
} from "typeorm";

@Entity("device_token")
@Index(["eName", "deviceId"], { unique: true })
@Index(["eName"], { unique: true })
export class DeviceToken {
@PrimaryGeneratedColumn("uuid")
id!: string;

@Column({ type: "varchar" })
eName!: string;

@Column({ type: "varchar" })
token!: string;

@Column({ type: "varchar" })
platform!: string;

@Column({ type: "varchar" })
deviceId!: string;
@Column({ type: "text", array: true, default: "{}" })
tokens!: string[];

@CreateDateColumn()
createdAt!: Date;
Expand Down
4 changes: 2 additions & 2 deletions infrastructure/evault-core/src/entities/Verification.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,8 +44,8 @@ export class Verification {
@Column({ type: "varchar", nullable: true })
platform!: string;

@Column({ type: "varchar", nullable: true })
fcmToken!: string;
@Column({ type: "text", array: true, default: "{}", nullable: true })
pushTokens!: string[];

@Column({ type: "boolean", default: true })
deviceActive!: boolean;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class Migration1773400000000 implements MigrationInterface {
name = "Migration1773400000000";

public async up(queryRunner: QueryRunner): Promise<void> {
// 1. Restructure device_token: collapse multi-row-per-eName into one-row-per-eName with tokens array

await queryRunner.query(`
CREATE TABLE "device_token_new" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"eName" character varying NOT NULL,
"tokens" text[] NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_device_token_new" PRIMARY KEY ("id")
)
`);

await queryRunner.query(`
INSERT INTO "device_token_new" ("eName", "tokens", "createdAt", "updatedAt")
SELECT
"eName",
array_agg(DISTINCT "token"),
MIN("createdAt"),
MAX("updatedAt")
FROM "device_token"
GROUP BY "eName"
`);

await queryRunner.query(`DROP TABLE "device_token"`);
await queryRunner.query(`ALTER TABLE "device_token_new" RENAME TO "device_token"`);
await queryRunner.query(`ALTER TABLE "device_token" RENAME CONSTRAINT "PK_device_token_new" TO "PK_device_token"`);
await queryRunner.query(`CREATE UNIQUE INDEX "UQ_device_token_ename" ON "device_token" ("eName")`);

// 2. Rename verification.fcmToken -> pushTokens (varchar -> text[])

await queryRunner.query(`ALTER TABLE "verification" ADD "pushTokens" text[] DEFAULT '{}'`);
await queryRunner.query(`
UPDATE "verification"
SET "pushTokens" = CASE
WHEN "fcmToken" IS NOT NULL AND "fcmToken" != '' THEN ARRAY["fcmToken"]
ELSE '{}'
END
`);
await queryRunner.query(`ALTER TABLE "verification" DROP COLUMN "fcmToken"`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
// Revert verification: pushTokens -> fcmToken
await queryRunner.query(`ALTER TABLE "verification" ADD "fcmToken" character varying`);
await queryRunner.query(`
UPDATE "verification"
SET "fcmToken" = CASE
WHEN "pushTokens" IS NOT NULL AND array_length("pushTokens", 1) > 0 THEN "pushTokens"[1]
ELSE NULL
END
`);
await queryRunner.query(`ALTER TABLE "verification" DROP COLUMN "pushTokens"`);

// Revert device_token: expand array rows back into individual rows
await queryRunner.query(`DROP INDEX "UQ_device_token_ename"`);

await queryRunner.query(`
CREATE TABLE "device_token_old" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"eName" character varying NOT NULL,
"token" character varying NOT NULL,
"platform" character varying NOT NULL DEFAULT '',
"deviceId" character varying NOT NULL DEFAULT '',
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_device_token_old" PRIMARY KEY ("id")
)
`);

await queryRunner.query(`
INSERT INTO "device_token_old" ("eName", "token", "createdAt", "updatedAt")
SELECT "eName", unnest("tokens"), "createdAt", "updatedAt"
FROM "device_token"
`);

await queryRunner.query(`DROP TABLE "device_token"`);
await queryRunner.query(`ALTER TABLE "device_token_old" RENAME TO "device_token"`);
await queryRunner.query(`ALTER TABLE "device_token" RENAME CONSTRAINT "PK_device_token_old" TO "PK_device_token"`);
await queryRunner.query(`CREATE UNIQUE INDEX "UQ_device_token_ename_deviceid" ON "device_token" ("eName", "deviceId")`);
await queryRunner.query(`CREATE INDEX "IDX_device_token_ename" ON "device_token" ("eName")`);
Comment on lines +77 to +87

@coderabbitaicoderabbitaiBotMar 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Down migration will fail with unique constraint violation.

When unnesting the tokens array back to individual rows, all rows get deviceId = '' (empty string). If any eName had multiple tokens, creating the unique index UQ_device_token_ename_deviceid on ("eName", "deviceId") will fail because multiple rows will have identical (eName, '') tuples.

Additionally, platform data was lost in the up migration, so all restored rows will have platform = '', which may break application logic expecting valid platform values.

🛠️ Suggested approach

Consider one of these options:

  1. Skip restoring the unique index in down migration if exact rollback fidelity isn't required:
- await queryRunner.query(`CREATE UNIQUE INDEX "UQ_device_token_ename_deviceid" ON "device_token" ("eName", "deviceId")`);+ // Note: Cannot restore original unique constraint as deviceId data was lost in up migration+ await queryRunner.query(`CREATE INDEX "IDX_device_token_ename_deviceid" ON "device_token" ("eName", "deviceId")`);
  1. Preserve platform/deviceId in up migration by storing them in a separate structure or JSONB column if rollback fidelity is important.

  2. Document that this migration is not safely reversible if the data loss is acceptable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@infrastructure/evault-core/src/migrations/1773400000000-MultiDeviceTokens.ts`
around lines 77 - 87, The down migration currently unnests tokens into
"device_token_old" but sets deviceId and platform to empty strings, so creating
the unique index UQ_device_token_ename_deviceid on ("eName","deviceId") will
fail when an eName has multiple rows; to fix, modify the down migration logic
that runs after the INSERT/RENAME (references: the INSERT INTO
"device_token_old" SELECT ... FROM "device_token", the RENAME TO "device_token",
and the CREATE UNIQUE INDEX "UQ_device_token_ename_deviceid") to not create the
unique index (remove or guard the CREATE UNIQUE INDEX
"UQ_device_token_ename_deviceid" statement) OR else preserve real
deviceId/platform during the up migration so they can be restored here; pick one
approach and update the migration accordingly and add a comment that the down
migration is not fully reversible if you choose to skip the index.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we only move forward, shinzou wo sasageyo

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sosweetham — Understood, forward only! 🫡 Acknowledged and noted for future reviews — I won't flag down migration reversibility issues for this project.

(∩ᵒ ᵕ ᵒ∩)
shinzou wo sasageyo


✏️ Learnings added
Learnt from: sosweetham
Repo: MetaState-Prototype-Project/prototype PR: 913
File: infrastructure/evault-core/src/migrations/1773400000000-MultiDeviceTokens.ts:77-87
Timestamp: 2026-03-12T11:03:53.941Z
Learning: The MetaState-Prototype-Project team follows a forward-only migration philosophy. Down migration reversibility is intentionally not a requirement. Do not flag issues about down migrations lacking full rollback fidelity (e.g., lost columns, unrestorable unique indexes) in this repository.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: coodos
Repo: MetaState-Prototype-Project/prototype PR: 691
File: platforms/dreamsync-api/src/database/migrations/1768904445609-migration.ts:6-11
Timestamp: 2026-01-20T10:41:46.622Z
Learning: Guideline: For Wishlist-like data, treat derived fields (e.g., summaryWants, summaryOffers) as non-persistent and regenerable from the canonical content. Ensure migrations and models do not rely on persisting these derived fields; only the core wishlist content should be stored and preserved. In reviews, verify that code paths and DB schemas do not mandate upgrading or snapshotting derived fields, and that any recomputation logic is idempotent and safe to run on startup or migration.

}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,9 +48,7 @@ export async function sendNotification(request: SendNotificationRequest): Promis
}
}

export async function getDevicesWithTokens(): Promise<
{ token: string; platform: string; eName: string }[]
> {
export async function getDevicesWithTokens(): Promise<{ token: string; eName: string }[]> {
const { env } = await import('$env/dynamic/private');
const provisionerUrl =
env.PUBLIC_PROVISIONER_URL || env.PROVISIONER_URL || 'http://localhost:3001';
Expand All@@ -69,7 +67,7 @@ export async function getDevicesWithTokens(): Promise<

export async function getDevicesByEName(
eName: string
): Promise<{ token: string; platform: string; eName: string }[]> {
): Promise<{ token: string; eName: string }[]> {
const { env } = await import('$env/dynamic/private');
const provisionerUrl =
env.PUBLIC_PROVISIONER_URL || env.PROVISIONER_URL || 'http://localhost:3001';
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@ export interface DeviceRegistration {
eName: string;
deviceId: string;
platform: "android" | "ios" | "desktop";
fcmToken?: string; // For Android/iOS push notifications
pushToken?: string;
registrationTime: Date;
}

Expand DownExpand Up@@ -76,17 +76,16 @@ class NotificationService {
throw new Error("Notification permissions not granted");
}

// Get FCM token for mobile platforms
let fcmToken: string | undefined;
let pushToken: string | undefined;
if (platform === "android" || platform === "ios") {
fcmToken = await this.getFCMToken();
pushToken = await this.getPushNotificationToken();
}

const registration: DeviceRegistration = {
eName,
deviceId,
platform,
fcmToken,
pushToken,
registrationTime: new Date(),
};

Expand DownExpand Up@@ -173,6 +172,7 @@ class NotificationService {
body: JSON.stringify({
eName: this.deviceRegistration.eName,
deviceId: this.deviceRegistration.deviceId,
pushToken: this.deviceRegistration.pushToken,
}),
},
);
Expand DownExpand Up@@ -342,9 +342,9 @@ class NotificationService {
}

/**
* Get push notification token (FCM on Android, APNs on iOS)
* Get push notification token from the platform (FCM on Android, APNs on iOS).
*/
private async getFCMToken(): Promise<string | undefined> {
private async getPushNotificationToken(): Promise<string | undefined> {
try {
return await registerForPushNotifications();
} catch (error) {
Expand All@@ -354,15 +354,15 @@ class NotificationService {
}

/**
* Request permissions and get push notification token (FCM on Android, APNs on iOS).
* Request permissions and get push notification token.
* Returns undefined on desktop or if permission is denied.
*/
async getPushToken(): Promise<string | undefined> {
const hasPermission = await this.requestPermissions();
if (!hasPermission) return undefined;
const platform = await this.getPlatform();
if (platform !== "android" && platform !== "ios") return undefined;
return this.getFCMToken();
return this.getPushNotificationToken();
}
/**
* Get eName from vault (helper method)
Expand Down
28 changes: 24 additions & 4 deletions infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte
Original file line numberDiff line numberDiff line change
Expand Up@@ -96,12 +96,22 @@ onMount(async () => {
);
} catch (error) {
console.error("Error syncing public key:", error);
// Continue to app even if sync fails - non-blocking
}

// Register device for push notifications on login
try {
await globalState.notificationService.registerDevice(
vault.ename,
);
} catch (error) {
console.error(
"Error registering device for notifications:",
error,
);
}
}
} catch (error) {
console.error("Error during eVault health check:", error);
// Continue to app even if health check fails - non-blocking
}

// Check if there's a pending deep link to process
Expand DownExpand Up@@ -176,12 +186,22 @@ onMount(async () => {
);
} catch (error) {
console.error("Error syncing public key:", error);
// Continue to app even if sync fails - non-blocking
}

// Register device for push notifications on biometric login
try {
await globalState.notificationService.registerDevice(
vault.ename,
);
} catch (error) {
console.error(
"Error registering device for notifications:",
error,
);
}
}
} catch (error) {
console.error("Error during eVault health check:", error);
// Continue to app even if health check fails - non-blocking
}

// Check if there's a pending deep link to process
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,7 +65,7 @@ export class NotificationController {

private async registerDevice(req: Request, res: Response) {
try {
const { eName, deviceId, platform, fcmToken } = req.body;
const { eName, deviceId, platform, pushToken } = req.body;

if (!eName || !deviceId || !platform) {
return res.status(400).json({
Expand All@@ -74,20 +74,17 @@ export class NotificationController {
});
}

if (fcmToken && typeof fcmToken === "string" && fcmToken.trim()) {
await this.deviceTokenService.register({
eName,
deviceId,
platform,
token: fcmToken.trim(),
});
const token = typeof pushToken === "string" ? pushToken.trim() : undefined;

if (token) {
await this.deviceTokenService.register(eName, token);
}

const verification = await this.notificationService.registerDevice({
eName,
deviceId,
platform,
fcmToken: fcmToken.trim(),
pushToken: token,
registrationTime: new Date(),
});

Expand All@@ -107,7 +104,7 @@ export class NotificationController {

private async unregisterDevice(req: Request, res: Response) {
try {
const { eName, deviceId } = req.body;
const { eName, deviceId, pushToken } = req.body;

if (!eName || !deviceId) {
return res.status(400).json({
Expand All@@ -116,7 +113,11 @@ export class NotificationController {
});
}

await this.deviceTokenService.unregister(eName, deviceId);
const token = typeof pushToken === "string" ? pushToken.trim() : undefined;
if (token) {
await this.deviceTokenService.unregister(eName, token);
}

const success = await this.notificationService.unregisterDevice(eName, deviceId);

res.json({
Expand Down
12 changes: 3 additions & 9 deletions infrastructure/evault-core/src/entities/DeviceToken.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,22 +8,16 @@ import {
} from "typeorm";

@Entity("device_token")
@Index(["eName", "deviceId"], { unique: true })
@Index(["eName"], { unique: true })
export class DeviceToken {
@PrimaryGeneratedColumn("uuid")
id!: string;

@Column({ type: "varchar" })
eName!: string;

@Column({ type: "varchar" })
token!: string;

@Column({ type: "varchar" })
platform!: string;

@Column({ type: "varchar" })
deviceId!: string;
@Column({ type: "text", array: true, default: "{}" })
tokens!: string[];

@CreateDateColumn()
createdAt!: Date;
Expand Down
4 changes: 2 additions & 2 deletions infrastructure/evault-core/src/entities/Verification.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,8 +44,8 @@ export class Verification {
@Column({ type: "varchar", nullable: true })
platform!: string;

@Column({ type: "varchar", nullable: true })
fcmToken!: string;
@Column({ type: "text", array: true, default: "{}", nullable: true })
pushTokens!: string[];

@Column({ type: "boolean", default: true })
deviceActive!: boolean;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class Migration1773400000000 implements MigrationInterface {
name = "Migration1773400000000";

public async up(queryRunner: QueryRunner): Promise<void> {
// 1. Restructure device_token: collapse multi-row-per-eName into one-row-per-eName with tokens array

await queryRunner.query(`
CREATE TABLE "device_token_new" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"eName" character varying NOT NULL,
"tokens" text[] NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_device_token_new" PRIMARY KEY ("id")
)
`);

await queryRunner.query(`
INSERT INTO "device_token_new" ("eName", "tokens", "createdAt", "updatedAt")
SELECT
"eName",
array_agg(DISTINCT "token"),
MIN("createdAt"),
MAX("updatedAt")
FROM "device_token"
GROUP BY "eName"
`);

await queryRunner.query(`DROP TABLE "device_token"`);
await queryRunner.query(`ALTER TABLE "device_token_new" RENAME TO "device_token"`);
await queryRunner.query(`ALTER TABLE "device_token" RENAME CONSTRAINT "PK_device_token_new" TO "PK_device_token"`);
await queryRunner.query(`CREATE UNIQUE INDEX "UQ_device_token_ename" ON "device_token" ("eName")`);

// 2. Rename verification.fcmToken -> pushTokens (varchar -> text[])

await queryRunner.query(`ALTER TABLE "verification" ADD "pushTokens" text[] DEFAULT '{}'`);
await queryRunner.query(`
UPDATE "verification"
SET "pushTokens" = CASE
WHEN "fcmToken" IS NOT NULL AND "fcmToken" != '' THEN ARRAY["fcmToken"]
ELSE '{}'
END
`);
await queryRunner.query(`ALTER TABLE "verification" DROP COLUMN "fcmToken"`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
// Revert verification: pushTokens -> fcmToken
await queryRunner.query(`ALTER TABLE "verification" ADD "fcmToken" character varying`);
await queryRunner.query(`
UPDATE "verification"
SET "fcmToken" = CASE
WHEN "pushTokens" IS NOT NULL AND array_length("pushTokens", 1) > 0 THEN "pushTokens"[1]
ELSE NULL
END
`);
await queryRunner.query(`ALTER TABLE "verification" DROP COLUMN "pushTokens"`);

// Revert device_token: expand array rows back into individual rows
await queryRunner.query(`DROP INDEX "UQ_device_token_ename"`);

await queryRunner.query(`
CREATE TABLE "device_token_old" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"eName" character varying NOT NULL,
"token" character varying NOT NULL,
"platform" character varying NOT NULL DEFAULT '',
"deviceId" character varying NOT NULL DEFAULT '',
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_device_token_old" PRIMARY KEY ("id")
)
`);

await queryRunner.query(`
INSERT INTO "device_token_old" ("eName", "token", "createdAt", "updatedAt")
SELECT "eName", unnest("tokens"), "createdAt", "updatedAt"
FROM "device_token"
`);

await queryRunner.query(`DROP TABLE "device_token"`);
await queryRunner.query(`ALTER TABLE "device_token_old" RENAME TO "device_token"`);
await queryRunner.query(`ALTER TABLE "device_token" RENAME CONSTRAINT "PK_device_token_old" TO "PK_device_token"`);
await queryRunner.query(`CREATE UNIQUE INDEX "UQ_device_token_ename_deviceid" ON "device_token" ("eName", "deviceId")`);
await queryRunner.query(`CREATE INDEX "IDX_device_token_ename" ON "device_token" ("eName")`);
Comment on lines +77 to +87

@coderabbitaicoderabbitaiBotMar 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Down migration will fail with unique constraint violation.

When unnesting the tokens array back to individual rows, all rows get deviceId = '' (empty string). If any eName had multiple tokens, creating the unique index UQ_device_token_ename_deviceid on ("eName", "deviceId") will fail because multiple rows will have identical (eName, '') tuples.

Additionally, platform data was lost in the up migration, so all restored rows will have platform = '', which may break application logic expecting valid platform values.

🛠️ Suggested approach

Consider one of these options:

  1. Skip restoring the unique index in down migration if exact rollback fidelity isn't required:
- await queryRunner.query(`CREATE UNIQUE INDEX "UQ_device_token_ename_deviceid" ON "device_token" ("eName", "deviceId")`);+ // Note: Cannot restore original unique constraint as deviceId data was lost in up migration+ await queryRunner.query(`CREATE INDEX "IDX_device_token_ename_deviceid" ON "device_token" ("eName", "deviceId")`);
  1. Preserve platform/deviceId in up migration by storing them in a separate structure or JSONB column if rollback fidelity is important.

  2. Document that this migration is not safely reversible if the data loss is acceptable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@infrastructure/evault-core/src/migrations/1773400000000-MultiDeviceTokens.ts`
around lines 77 - 87, The down migration currently unnests tokens into
"device_token_old" but sets deviceId and platform to empty strings, so creating
the unique index UQ_device_token_ename_deviceid on ("eName","deviceId") will
fail when an eName has multiple rows; to fix, modify the down migration logic
that runs after the INSERT/RENAME (references: the INSERT INTO
"device_token_old" SELECT ... FROM "device_token", the RENAME TO "device_token",
and the CREATE UNIQUE INDEX "UQ_device_token_ename_deviceid") to not create the
unique index (remove or guard the CREATE UNIQUE INDEX
"UQ_device_token_ename_deviceid" statement) OR else preserve real
deviceId/platform during the up migration so they can be restored here; pick one
approach and update the migration accordingly and add a comment that the down
migration is not fully reversible if you choose to skip the index.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we only move forward, shinzou wo sasageyo

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sosweetham — Understood, forward only! 🫡 Acknowledged and noted for future reviews — I won't flag down migration reversibility issues for this project.

(∩ᵒ ᵕ ᵒ∩)
shinzou wo sasageyo


✏️ Learnings added
Learnt from: sosweetham
Repo: MetaState-Prototype-Project/prototype PR: 913
File: infrastructure/evault-core/src/migrations/1773400000000-MultiDeviceTokens.ts:77-87
Timestamp: 2026-03-12T11:03:53.941Z
Learning: The MetaState-Prototype-Project team follows a forward-only migration philosophy. Down migration reversibility is intentionally not a requirement. Do not flag issues about down migrations lacking full rollback fidelity (e.g., lost columns, unrestorable unique indexes) in this repository.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: coodos
Repo: MetaState-Prototype-Project/prototype PR: 691
File: platforms/dreamsync-api/src/database/migrations/1768904445609-migration.ts:6-11
Timestamp: 2026-01-20T10:41:46.622Z
Learning: Guideline: For Wishlist-like data, treat derived fields (e.g., summaryWants, summaryOffers) as non-persistent and regenerable from the canonical content. Ensure migrations and models do not rely on persisting these derived fields; only the core wishlist content should be stored and preserved. In reviews, verify that code paths and DB schemas do not mandate upgrading or snapshotting derived fields, and that any recomputation logic is idempotent and safe to run on startup or migration.

}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
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
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,9 +48,7 @@ export async function sendNotification(request: SendNotificationRequest): Promis
}
}

export async function getDevicesWithTokens(): Promise<
{ token: string; platform: string; eName: string }[]
> {
export async function getDevicesWithTokens(): Promise<{ token: string; eName: string }[]> {
const { env } = await import('$env/dynamic/private');
const provisionerUrl =
env.PUBLIC_PROVISIONER_URL || env.PROVISIONER_URL || 'http://localhost:3001';
Expand All@@ -69,7 +67,7 @@ export async function getDevicesWithTokens(): Promise<

export async function getDevicesByEName(
eName: string
): Promise<{ token: string; platform: string; eName: string }[]> {
): Promise<{ token: string; eName: string }[]> {
const { env } = await import('$env/dynamic/private');
const provisionerUrl =
env.PUBLIC_PROVISIONER_URL || env.PROVISIONER_URL || 'http://localhost:3001';
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@ export interface DeviceRegistration {
eName: string;
deviceId: string;
platform: "android" | "ios" | "desktop";
fcmToken?: string; // For Android/iOS push notifications
pushToken?: string;
registrationTime: Date;
}

Expand DownExpand Up@@ -76,17 +76,16 @@ class NotificationService {
throw new Error("Notification permissions not granted");
}

// Get FCM token for mobile platforms
let fcmToken: string | undefined;
let pushToken: string | undefined;
if (platform === "android" || platform === "ios") {
fcmToken = await this.getFCMToken();
pushToken = await this.getPushNotificationToken();
}

const registration: DeviceRegistration = {
eName,
deviceId,
platform,
fcmToken,
pushToken,
registrationTime: new Date(),
};

Expand DownExpand Up@@ -173,6 +172,7 @@ class NotificationService {
body: JSON.stringify({
eName: this.deviceRegistration.eName,
deviceId: this.deviceRegistration.deviceId,
pushToken: this.deviceRegistration.pushToken,
}),
},
);
Expand DownExpand Up@@ -342,9 +342,9 @@ class NotificationService {
}

/**
* Get push notification token (FCM on Android, APNs on iOS)
* Get push notification token from the platform (FCM on Android, APNs on iOS).
*/
private async getFCMToken(): Promise<string | undefined> {
private async getPushNotificationToken(): Promise<string | undefined> {
try {
return await registerForPushNotifications();
} catch (error) {
Expand All@@ -354,15 +354,15 @@ class NotificationService {
}

/**
* Request permissions and get push notification token (FCM on Android, APNs on iOS).
* Request permissions and get push notification token.
* Returns undefined on desktop or if permission is denied.
*/
async getPushToken(): Promise<string | undefined> {
const hasPermission = await this.requestPermissions();
if (!hasPermission) return undefined;
const platform = await this.getPlatform();
if (platform !== "android" && platform !== "ios") return undefined;
return this.getFCMToken();
return this.getPushNotificationToken();
}
/**
* Get eName from vault (helper method)
Expand Down
28 changes: 24 additions & 4 deletions infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte
Original file line numberDiff line numberDiff line change
Expand Up@@ -96,12 +96,22 @@ onMount(async () => {
);
} catch (error) {
console.error("Error syncing public key:", error);
// Continue to app even if sync fails - non-blocking
}

// Register device for push notifications on login
try {
await globalState.notificationService.registerDevice(
vault.ename,
);
} catch (error) {
console.error(
"Error registering device for notifications:",
error,
);
}
}
} catch (error) {
console.error("Error during eVault health check:", error);
// Continue to app even if health check fails - non-blocking
}

// Check if there's a pending deep link to process
Expand DownExpand Up@@ -176,12 +186,22 @@ onMount(async () => {
);
} catch (error) {
console.error("Error syncing public key:", error);
// Continue to app even if sync fails - non-blocking
}

// Register device for push notifications on biometric login
try {
await globalState.notificationService.registerDevice(
vault.ename,
);
} catch (error) {
console.error(
"Error registering device for notifications:",
error,
);
}
}
} catch (error) {
console.error("Error during eVault health check:", error);
// Continue to app even if health check fails - non-blocking
}

// Check if there's a pending deep link to process
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,7 +65,7 @@ export class NotificationController {

private async registerDevice(req: Request, res: Response) {
try {
const { eName, deviceId, platform, fcmToken } = req.body;
const { eName, deviceId, platform, pushToken } = req.body;

if (!eName || !deviceId || !platform) {
return res.status(400).json({
Expand All@@ -74,20 +74,17 @@ export class NotificationController {
});
}

if (fcmToken && typeof fcmToken === "string" && fcmToken.trim()) {
await this.deviceTokenService.register({
eName,
deviceId,
platform,
token: fcmToken.trim(),
});
const token = typeof pushToken === "string" ? pushToken.trim() : undefined;

if (token) {
await this.deviceTokenService.register(eName, token);
}

const verification = await this.notificationService.registerDevice({
eName,
deviceId,
platform,
fcmToken: fcmToken.trim(),
pushToken: token,
registrationTime: new Date(),
});

Expand All@@ -107,7 +104,7 @@ export class NotificationController {

private async unregisterDevice(req: Request, res: Response) {
try {
const { eName, deviceId } = req.body;
const { eName, deviceId, pushToken } = req.body;

if (!eName || !deviceId) {
return res.status(400).json({
Expand All@@ -116,7 +113,11 @@ export class NotificationController {
});
}

await this.deviceTokenService.unregister(eName, deviceId);
const token = typeof pushToken === "string" ? pushToken.trim() : undefined;
if (token) {
await this.deviceTokenService.unregister(eName, token);
}

const success = await this.notificationService.unregisterDevice(eName, deviceId);

res.json({
Expand Down
12 changes: 3 additions & 9 deletions infrastructure/evault-core/src/entities/DeviceToken.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,22 +8,16 @@ import {
} from "typeorm";

@Entity("device_token")
@Index(["eName", "deviceId"], { unique: true })
@Index(["eName"], { unique: true })
export class DeviceToken {
@PrimaryGeneratedColumn("uuid")
id!: string;

@Column({ type: "varchar" })
eName!: string;

@Column({ type: "varchar" })
token!: string;

@Column({ type: "varchar" })
platform!: string;

@Column({ type: "varchar" })
deviceId!: string;
@Column({ type: "text", array: true, default: "{}" })
tokens!: string[];

@CreateDateColumn()
createdAt!: Date;
Expand Down
4 changes: 2 additions & 2 deletions infrastructure/evault-core/src/entities/Verification.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,8 +44,8 @@ export class Verification {
@Column({ type: "varchar", nullable: true })
platform!: string;

@Column({ type: "varchar", nullable: true })
fcmToken!: string;
@Column({ type: "text", array: true, default: "{}", nullable: true })
pushTokens!: string[];

@Column({ type: "boolean", default: true })
deviceActive!: boolean;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class Migration1773400000000 implements MigrationInterface {
name = "Migration1773400000000";

public async up(queryRunner: QueryRunner): Promise<void> {
// 1. Restructure device_token: collapse multi-row-per-eName into one-row-per-eName with tokens array

await queryRunner.query(`
CREATE TABLE "device_token_new" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"eName" character varying NOT NULL,
"tokens" text[] NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_device_token_new" PRIMARY KEY ("id")
)
`);

await queryRunner.query(`
INSERT INTO "device_token_new" ("eName", "tokens", "createdAt", "updatedAt")
SELECT
"eName",
array_agg(DISTINCT "token"),
MIN("createdAt"),
MAX("updatedAt")
FROM "device_token"
GROUP BY "eName"
`);

await queryRunner.query(`DROP TABLE "device_token"`);
await queryRunner.query(`ALTER TABLE "device_token_new" RENAME TO "device_token"`);
await queryRunner.query(`ALTER TABLE "device_token" RENAME CONSTRAINT "PK_device_token_new" TO "PK_device_token"`);
await queryRunner.query(`CREATE UNIQUE INDEX "UQ_device_token_ename" ON "device_token" ("eName")`);

// 2. Rename verification.fcmToken -> pushTokens (varchar -> text[])

await queryRunner.query(`ALTER TABLE "verification" ADD "pushTokens" text[] DEFAULT '{}'`);
await queryRunner.query(`
UPDATE "verification"
SET "pushTokens" = CASE
WHEN "fcmToken" IS NOT NULL AND "fcmToken" != '' THEN ARRAY["fcmToken"]
ELSE '{}'
END
`);
await queryRunner.query(`ALTER TABLE "verification" DROP COLUMN "fcmToken"`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
// Revert verification: pushTokens -> fcmToken
await queryRunner.query(`ALTER TABLE "verification" ADD "fcmToken" character varying`);
await queryRunner.query(`
UPDATE "verification"
SET "fcmToken" = CASE
WHEN "pushTokens" IS NOT NULL AND array_length("pushTokens", 1) > 0 THEN "pushTokens"[1]
ELSE NULL
END
`);
await queryRunner.query(`ALTER TABLE "verification" DROP COLUMN "pushTokens"`);

// Revert device_token: expand array rows back into individual rows
await queryRunner.query(`DROP INDEX "UQ_device_token_ename"`);

await queryRunner.query(`
CREATE TABLE "device_token_old" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"eName" character varying NOT NULL,
"token" character varying NOT NULL,
"platform" character varying NOT NULL DEFAULT '',
"deviceId" character varying NOT NULL DEFAULT '',
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_device_token_old" PRIMARY KEY ("id")
)
`);

await queryRunner.query(`
INSERT INTO "device_token_old" ("eName", "token", "createdAt", "updatedAt")
SELECT "eName", unnest("tokens"), "createdAt", "updatedAt"
FROM "device_token"
`);

await queryRunner.query(`DROP TABLE "device_token"`);
await queryRunner.query(`ALTER TABLE "device_token_old" RENAME TO "device_token"`);
await queryRunner.query(`ALTER TABLE "device_token" RENAME CONSTRAINT "PK_device_token_old" TO "PK_device_token"`);
await queryRunner.query(`CREATE UNIQUE INDEX "UQ_device_token_ename_deviceid" ON "device_token" ("eName", "deviceId")`);
await queryRunner.query(`CREATE INDEX "IDX_device_token_ename" ON "device_token" ("eName")`);
Comment on lines +77 to +87

@coderabbitaicoderabbitaiBotMar 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Down migration will fail with unique constraint violation.

When unnesting the tokens array back to individual rows, all rows get deviceId = '' (empty string). If any eName had multiple tokens, creating the unique index UQ_device_token_ename_deviceid on ("eName", "deviceId") will fail because multiple rows will have identical (eName, '') tuples.

Additionally, platform data was lost in the up migration, so all restored rows will have platform = '', which may break application logic expecting valid platform values.

🛠️ Suggested approach

Consider one of these options:

  1. Skip restoring the unique index in down migration if exact rollback fidelity isn't required:
- await queryRunner.query(`CREATE UNIQUE INDEX "UQ_device_token_ename_deviceid" ON "device_token" ("eName", "deviceId")`);+ // Note: Cannot restore original unique constraint as deviceId data was lost in up migration+ await queryRunner.query(`CREATE INDEX "IDX_device_token_ename_deviceid" ON "device_token" ("eName", "deviceId")`);
  1. Preserve platform/deviceId in up migration by storing them in a separate structure or JSONB column if rollback fidelity is important.

  2. Document that this migration is not safely reversible if the data loss is acceptable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@infrastructure/evault-core/src/migrations/1773400000000-MultiDeviceTokens.ts`
around lines 77 - 87, The down migration currently unnests tokens into
"device_token_old" but sets deviceId and platform to empty strings, so creating
the unique index UQ_device_token_ename_deviceid on ("eName","deviceId") will
fail when an eName has multiple rows; to fix, modify the down migration logic
that runs after the INSERT/RENAME (references: the INSERT INTO
"device_token_old" SELECT ... FROM "device_token", the RENAME TO "device_token",
and the CREATE UNIQUE INDEX "UQ_device_token_ename_deviceid") to not create the
unique index (remove or guard the CREATE UNIQUE INDEX
"UQ_device_token_ename_deviceid" statement) OR else preserve real
deviceId/platform during the up migration so they can be restored here; pick one
approach and update the migration accordingly and add a comment that the down
migration is not fully reversible if you choose to skip the index.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we only move forward, shinzou wo sasageyo

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sosweetham — Understood, forward only! 🫡 Acknowledged and noted for future reviews — I won't flag down migration reversibility issues for this project.

(∩ᵒ ᵕ ᵒ∩)
shinzou wo sasageyo


✏️ Learnings added
Learnt from: sosweetham
Repo: MetaState-Prototype-Project/prototype PR: 913
File: infrastructure/evault-core/src/migrations/1773400000000-MultiDeviceTokens.ts:77-87
Timestamp: 2026-03-12T11:03:53.941Z
Learning: The MetaState-Prototype-Project team follows a forward-only migration philosophy. Down migration reversibility is intentionally not a requirement. Do not flag issues about down migrations lacking full rollback fidelity (e.g., lost columns, unrestorable unique indexes) in this repository.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: coodos
Repo: MetaState-Prototype-Project/prototype PR: 691
File: platforms/dreamsync-api/src/database/migrations/1768904445609-migration.ts:6-11
Timestamp: 2026-01-20T10:41:46.622Z
Learning: Guideline: For Wishlist-like data, treat derived fields (e.g., summaryWants, summaryOffers) as non-persistent and regenerable from the canonical content. Ensure migrations and models do not rely on persisting these derived fields; only the core wishlist content should be stored and preserved. In reviews, verify that code paths and DB schemas do not mandate upgrading or snapshotting derived fields, and that any recomputation logic is idempotent and safe to run on startup or migration.

}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,9 +48,7 @@ export async function sendNotification(request: SendNotificationRequest): Promis
}
}

export async function getDevicesWithTokens(): Promise<
{ token: string; platform: string; eName: string }[]
> {
export async function getDevicesWithTokens(): Promise<{ token: string; eName: string }[]> {
const { env } = await import('$env/dynamic/private');
const provisionerUrl =
env.PUBLIC_PROVISIONER_URL || env.PROVISIONER_URL || 'http://localhost:3001';
Expand All@@ -69,7 +67,7 @@ export async function getDevicesWithTokens(): Promise<

export async function getDevicesByEName(
eName: string
): Promise<{ token: string; platform: string; eName: string }[]> {
): Promise<{ token: string; eName: string }[]> {
const { env } = await import('$env/dynamic/private');
const provisionerUrl =
env.PUBLIC_PROVISIONER_URL || env.PROVISIONER_URL || 'http://localhost:3001';
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@ export interface DeviceRegistration {
eName: string;
deviceId: string;
platform: "android" | "ios" | "desktop";
fcmToken?: string; // For Android/iOS push notifications
pushToken?: string;
registrationTime: Date;
}

Expand DownExpand Up@@ -76,17 +76,16 @@ class NotificationService {
throw new Error("Notification permissions not granted");
}

// Get FCM token for mobile platforms
let fcmToken: string | undefined;
let pushToken: string | undefined;
if (platform === "android" || platform === "ios") {
fcmToken = await this.getFCMToken();
pushToken = await this.getPushNotificationToken();
}

const registration: DeviceRegistration = {
eName,
deviceId,
platform,
fcmToken,
pushToken,
registrationTime: new Date(),
};

Expand DownExpand Up@@ -173,6 +172,7 @@ class NotificationService {
body: JSON.stringify({
eName: this.deviceRegistration.eName,
deviceId: this.deviceRegistration.deviceId,
pushToken: this.deviceRegistration.pushToken,
}),
},
);
Expand DownExpand Up@@ -342,9 +342,9 @@ class NotificationService {
}

/**
* Get push notification token (FCM on Android, APNs on iOS)
* Get push notification token from the platform (FCM on Android, APNs on iOS).
*/
private async getFCMToken(): Promise<string | undefined> {
private async getPushNotificationToken(): Promise<string | undefined> {
try {
return await registerForPushNotifications();
} catch (error) {
Expand All@@ -354,15 +354,15 @@ class NotificationService {
}

/**
* Request permissions and get push notification token (FCM on Android, APNs on iOS).
* Request permissions and get push notification token.
* Returns undefined on desktop or if permission is denied.
*/
async getPushToken(): Promise<string | undefined> {
const hasPermission = await this.requestPermissions();
if (!hasPermission) return undefined;
const platform = await this.getPlatform();
if (platform !== "android" && platform !== "ios") return undefined;
return this.getFCMToken();
return this.getPushNotificationToken();
}
/**
* Get eName from vault (helper method)
Expand Down
28 changes: 24 additions & 4 deletions infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte
Original file line numberDiff line numberDiff line change
Expand Up@@ -96,12 +96,22 @@ onMount(async () => {
);
} catch (error) {
console.error("Error syncing public key:", error);
// Continue to app even if sync fails - non-blocking
}

// Register device for push notifications on login
try {
await globalState.notificationService.registerDevice(
vault.ename,
);
} catch (error) {
console.error(
"Error registering device for notifications:",
error,
);
}
}
} catch (error) {
console.error("Error during eVault health check:", error);
// Continue to app even if health check fails - non-blocking
}

// Check if there's a pending deep link to process
Expand DownExpand Up@@ -176,12 +186,22 @@ onMount(async () => {
);
} catch (error) {
console.error("Error syncing public key:", error);
// Continue to app even if sync fails - non-blocking
}

// Register device for push notifications on biometric login
try {
await globalState.notificationService.registerDevice(
vault.ename,
);
} catch (error) {
console.error(
"Error registering device for notifications:",
error,
);
}
}
} catch (error) {
console.error("Error during eVault health check:", error);
// Continue to app even if health check fails - non-blocking
}

// Check if there's a pending deep link to process
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,7 +65,7 @@ export class NotificationController {

private async registerDevice(req: Request, res: Response) {
try {
const { eName, deviceId, platform, fcmToken } = req.body;
const { eName, deviceId, platform, pushToken } = req.body;

if (!eName || !deviceId || !platform) {
return res.status(400).json({
Expand All@@ -74,20 +74,17 @@ export class NotificationController {
});
}

if (fcmToken && typeof fcmToken === "string" && fcmToken.trim()) {
await this.deviceTokenService.register({
eName,
deviceId,
platform,
token: fcmToken.trim(),
});
const token = typeof pushToken === "string" ? pushToken.trim() : undefined;

if (token) {
await this.deviceTokenService.register(eName, token);
}

const verification = await this.notificationService.registerDevice({
eName,
deviceId,
platform,
fcmToken: fcmToken.trim(),
pushToken: token,
registrationTime: new Date(),
});

Expand All@@ -107,7 +104,7 @@ export class NotificationController {

private async unregisterDevice(req: Request, res: Response) {
try {
const { eName, deviceId } = req.body;
const { eName, deviceId, pushToken } = req.body;

if (!eName || !deviceId) {
return res.status(400).json({
Expand All@@ -116,7 +113,11 @@ export class NotificationController {
});
}

await this.deviceTokenService.unregister(eName, deviceId);
const token = typeof pushToken === "string" ? pushToken.trim() : undefined;
if (token) {
await this.deviceTokenService.unregister(eName, token);
}

const success = await this.notificationService.unregisterDevice(eName, deviceId);

res.json({
Expand Down
12 changes: 3 additions & 9 deletions infrastructure/evault-core/src/entities/DeviceToken.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,22 +8,16 @@ import {
} from "typeorm";

@Entity("device_token")
@Index(["eName", "deviceId"], { unique: true })
@Index(["eName"], { unique: true })
export class DeviceToken {
@PrimaryGeneratedColumn("uuid")
id!: string;

@Column({ type: "varchar" })
eName!: string;

@Column({ type: "varchar" })
token!: string;

@Column({ type: "varchar" })
platform!: string;

@Column({ type: "varchar" })
deviceId!: string;
@Column({ type: "text", array: true, default: "{}" })
tokens!: string[];

@CreateDateColumn()
createdAt!: Date;
Expand Down
4 changes: 2 additions & 2 deletions infrastructure/evault-core/src/entities/Verification.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,8 +44,8 @@ export class Verification {
@Column({ type: "varchar", nullable: true })
platform!: string;

@Column({ type: "varchar", nullable: true })
fcmToken!: string;
@Column({ type: "text", array: true, default: "{}", nullable: true })
pushTokens!: string[];

@Column({ type: "boolean", default: true })
deviceActive!: boolean;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class Migration1773400000000 implements MigrationInterface {
name = "Migration1773400000000";

public async up(queryRunner: QueryRunner): Promise<void> {
// 1. Restructure device_token: collapse multi-row-per-eName into one-row-per-eName with tokens array

await queryRunner.query(`
CREATE TABLE "device_token_new" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"eName" character varying NOT NULL,
"tokens" text[] NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_device_token_new" PRIMARY KEY ("id")
)
`);

await queryRunner.query(`
INSERT INTO "device_token_new" ("eName", "tokens", "createdAt", "updatedAt")
SELECT
"eName",
array_agg(DISTINCT "token"),
MIN("createdAt"),
MAX("updatedAt")
FROM "device_token"
GROUP BY "eName"
`);

await queryRunner.query(`DROP TABLE "device_token"`);
await queryRunner.query(`ALTER TABLE "device_token_new" RENAME TO "device_token"`);
await queryRunner.query(`ALTER TABLE "device_token" RENAME CONSTRAINT "PK_device_token_new" TO "PK_device_token"`);
await queryRunner.query(`CREATE UNIQUE INDEX "UQ_device_token_ename" ON "device_token" ("eName")`);

// 2. Rename verification.fcmToken -> pushTokens (varchar -> text[])

await queryRunner.query(`ALTER TABLE "verification" ADD "pushTokens" text[] DEFAULT '{}'`);
await queryRunner.query(`
UPDATE "verification"
SET "pushTokens" = CASE
WHEN "fcmToken" IS NOT NULL AND "fcmToken" != '' THEN ARRAY["fcmToken"]
ELSE '{}'
END
`);
await queryRunner.query(`ALTER TABLE "verification" DROP COLUMN "fcmToken"`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
// Revert verification: pushTokens -> fcmToken
await queryRunner.query(`ALTER TABLE "verification" ADD "fcmToken" character varying`);
await queryRunner.query(`
UPDATE "verification"
SET "fcmToken" = CASE
WHEN "pushTokens" IS NOT NULL AND array_length("pushTokens", 1) > 0 THEN "pushTokens"[1]
ELSE NULL
END
`);
await queryRunner.query(`ALTER TABLE "verification" DROP COLUMN "pushTokens"`);

// Revert device_token: expand array rows back into individual rows
await queryRunner.query(`DROP INDEX "UQ_device_token_ename"`);

await queryRunner.query(`
CREATE TABLE "device_token_old" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"eName" character varying NOT NULL,
"token" character varying NOT NULL,
"platform" character varying NOT NULL DEFAULT '',
"deviceId" character varying NOT NULL DEFAULT '',
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_device_token_old" PRIMARY KEY ("id")
)
`);

await queryRunner.query(`
INSERT INTO "device_token_old" ("eName", "token", "createdAt", "updatedAt")
SELECT "eName", unnest("tokens"), "createdAt", "updatedAt"
FROM "device_token"
`);

await queryRunner.query(`DROP TABLE "device_token"`);
await queryRunner.query(`ALTER TABLE "device_token_old" RENAME TO "device_token"`);
await queryRunner.query(`ALTER TABLE "device_token" RENAME CONSTRAINT "PK_device_token_old" TO "PK_device_token"`);
await queryRunner.query(`CREATE UNIQUE INDEX "UQ_device_token_ename_deviceid" ON "device_token" ("eName", "deviceId")`);
await queryRunner.query(`CREATE INDEX "IDX_device_token_ename" ON "device_token" ("eName")`);
Comment on lines +77 to +87

@coderabbitaicoderabbitaiBotMar 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Down migration will fail with unique constraint violation.

When unnesting the tokens array back to individual rows, all rows get deviceId = '' (empty string). If any eName had multiple tokens, creating the unique index UQ_device_token_ename_deviceid on ("eName", "deviceId") will fail because multiple rows will have identical (eName, '') tuples.

Additionally, platform data was lost in the up migration, so all restored rows will have platform = '', which may break application logic expecting valid platform values.

🛠️ Suggested approach

Consider one of these options:

  1. Skip restoring the unique index in down migration if exact rollback fidelity isn't required:
- await queryRunner.query(`CREATE UNIQUE INDEX "UQ_device_token_ename_deviceid" ON "device_token" ("eName", "deviceId")`);+ // Note: Cannot restore original unique constraint as deviceId data was lost in up migration+ await queryRunner.query(`CREATE INDEX "IDX_device_token_ename_deviceid" ON "device_token" ("eName", "deviceId")`);
  1. Preserve platform/deviceId in up migration by storing them in a separate structure or JSONB column if rollback fidelity is important.

  2. Document that this migration is not safely reversible if the data loss is acceptable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@infrastructure/evault-core/src/migrations/1773400000000-MultiDeviceTokens.ts`
around lines 77 - 87, The down migration currently unnests tokens into
"device_token_old" but sets deviceId and platform to empty strings, so creating
the unique index UQ_device_token_ename_deviceid on ("eName","deviceId") will
fail when an eName has multiple rows; to fix, modify the down migration logic
that runs after the INSERT/RENAME (references: the INSERT INTO
"device_token_old" SELECT ... FROM "device_token", the RENAME TO "device_token",
and the CREATE UNIQUE INDEX "UQ_device_token_ename_deviceid") to not create the
unique index (remove or guard the CREATE UNIQUE INDEX
"UQ_device_token_ename_deviceid" statement) OR else preserve real
deviceId/platform during the up migration so they can be restored here; pick one
approach and update the migration accordingly and add a comment that the down
migration is not fully reversible if you choose to skip the index.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we only move forward, shinzou wo sasageyo

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sosweetham — Understood, forward only! 🫡 Acknowledged and noted for future reviews — I won't flag down migration reversibility issues for this project.

(∩ᵒ ᵕ ᵒ∩)
shinzou wo sasageyo


✏️ Learnings added
Learnt from: sosweetham
Repo: MetaState-Prototype-Project/prototype PR: 913
File: infrastructure/evault-core/src/migrations/1773400000000-MultiDeviceTokens.ts:77-87
Timestamp: 2026-03-12T11:03:53.941Z
Learning: The MetaState-Prototype-Project team follows a forward-only migration philosophy. Down migration reversibility is intentionally not a requirement. Do not flag issues about down migrations lacking full rollback fidelity (e.g., lost columns, unrestorable unique indexes) in this repository.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: coodos
Repo: MetaState-Prototype-Project/prototype PR: 691
File: platforms/dreamsync-api/src/database/migrations/1768904445609-migration.ts:6-11
Timestamp: 2026-01-20T10:41:46.622Z
Learning: Guideline: For Wishlist-like data, treat derived fields (e.g., summaryWants, summaryOffers) as non-persistent and regenerable from the canonical content. Ensure migrations and models do not rely on persisting these derived fields; only the core wishlist content should be stored and preserved. In reviews, verify that code paths and DB schemas do not mandate upgrading or snapshotting derived fields, and that any recomputation logic is idempotent and safe to run on startup or migration.

}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,9 +48,7 @@ export async function sendNotification(request: SendNotificationRequest): Promis
}
}

export async function getDevicesWithTokens(): Promise<
{ token: string; platform: string; eName: string }[]
> {
export async function getDevicesWithTokens(): Promise<{ token: string; eName: string }[]> {
const { env } = await import('$env/dynamic/private');
const provisionerUrl =
env.PUBLIC_PROVISIONER_URL || env.PROVISIONER_URL || 'http://localhost:3001';
Expand All@@ -69,7 +67,7 @@ export async function getDevicesWithTokens(): Promise<

export async function getDevicesByEName(
eName: string
): Promise<{ token: string; platform: string; eName: string }[]> {
): Promise<{ token: string; eName: string }[]> {
const { env } = await import('$env/dynamic/private');
const provisionerUrl =
env.PUBLIC_PROVISIONER_URL || env.PROVISIONER_URL || 'http://localhost:3001';
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@ export interface DeviceRegistration {
eName: string;
deviceId: string;
platform: "android" | "ios" | "desktop";
fcmToken?: string; // For Android/iOS push notifications
pushToken?: string;
registrationTime: Date;
}

Expand DownExpand Up@@ -76,17 +76,16 @@ class NotificationService {
throw new Error("Notification permissions not granted");
}

// Get FCM token for mobile platforms
let fcmToken: string | undefined;
let pushToken: string | undefined;
if (platform === "android" || platform === "ios") {
fcmToken = await this.getFCMToken();
pushToken = await this.getPushNotificationToken();
}

const registration: DeviceRegistration = {
eName,
deviceId,
platform,
fcmToken,
pushToken,
registrationTime: new Date(),
};

Expand DownExpand Up@@ -173,6 +172,7 @@ class NotificationService {
body: JSON.stringify({
eName: this.deviceRegistration.eName,
deviceId: this.deviceRegistration.deviceId,
pushToken: this.deviceRegistration.pushToken,
}),
},
);
Expand DownExpand Up@@ -342,9 +342,9 @@ class NotificationService {
}

/**
* Get push notification token (FCM on Android, APNs on iOS)
* Get push notification token from the platform (FCM on Android, APNs on iOS).
*/
private async getFCMToken(): Promise<string | undefined> {
private async getPushNotificationToken(): Promise<string | undefined> {
try {
return await registerForPushNotifications();
} catch (error) {
Expand All@@ -354,15 +354,15 @@ class NotificationService {
}

/**
* Request permissions and get push notification token (FCM on Android, APNs on iOS).
* Request permissions and get push notification token.
* Returns undefined on desktop or if permission is denied.
*/
async getPushToken(): Promise<string | undefined> {
const hasPermission = await this.requestPermissions();
if (!hasPermission) return undefined;
const platform = await this.getPlatform();
if (platform !== "android" && platform !== "ios") return undefined;
return this.getFCMToken();
return this.getPushNotificationToken();
}
/**
* Get eName from vault (helper method)
Expand Down
28 changes: 24 additions & 4 deletions infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte
Original file line numberDiff line numberDiff line change
Expand Up@@ -96,12 +96,22 @@ onMount(async () => {
);
} catch (error) {
console.error("Error syncing public key:", error);
// Continue to app even if sync fails - non-blocking
}

// Register device for push notifications on login
try {
await globalState.notificationService.registerDevice(
vault.ename,
);
} catch (error) {
console.error(
"Error registering device for notifications:",
error,
);
}
}
} catch (error) {
console.error("Error during eVault health check:", error);
// Continue to app even if health check fails - non-blocking
}

// Check if there's a pending deep link to process
Expand DownExpand Up@@ -176,12 +186,22 @@ onMount(async () => {
);
} catch (error) {
console.error("Error syncing public key:", error);
// Continue to app even if sync fails - non-blocking
}

// Register device for push notifications on biometric login
try {
await globalState.notificationService.registerDevice(
vault.ename,
);
} catch (error) {
console.error(
"Error registering device for notifications:",
error,
);
}
}
} catch (error) {
console.error("Error during eVault health check:", error);
// Continue to app even if health check fails - non-blocking
}

// Check if there's a pending deep link to process
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,7 +65,7 @@ export class NotificationController {

private async registerDevice(req: Request, res: Response) {
try {
const { eName, deviceId, platform, fcmToken } = req.body;
const { eName, deviceId, platform, pushToken } = req.body;

if (!eName || !deviceId || !platform) {
return res.status(400).json({
Expand All@@ -74,20 +74,17 @@ export class NotificationController {
});
}

if (fcmToken && typeof fcmToken === "string" && fcmToken.trim()) {
await this.deviceTokenService.register({
eName,
deviceId,
platform,
token: fcmToken.trim(),
});
const token = typeof pushToken === "string" ? pushToken.trim() : undefined;

if (token) {
await this.deviceTokenService.register(eName, token);
}

const verification = await this.notificationService.registerDevice({
eName,
deviceId,
platform,
fcmToken: fcmToken.trim(),
pushToken: token,
registrationTime: new Date(),
});

Expand All@@ -107,7 +104,7 @@ export class NotificationController {

private async unregisterDevice(req: Request, res: Response) {
try {
const { eName, deviceId } = req.body;
const { eName, deviceId, pushToken } = req.body;

if (!eName || !deviceId) {
return res.status(400).json({
Expand All@@ -116,7 +113,11 @@ export class NotificationController {
});
}

await this.deviceTokenService.unregister(eName, deviceId);
const token = typeof pushToken === "string" ? pushToken.trim() : undefined;
if (token) {
await this.deviceTokenService.unregister(eName, token);
}

const success = await this.notificationService.unregisterDevice(eName, deviceId);

res.json({
Expand Down
12 changes: 3 additions & 9 deletions infrastructure/evault-core/src/entities/DeviceToken.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,22 +8,16 @@ import {
} from "typeorm";

@Entity("device_token")
@Index(["eName", "deviceId"], { unique: true })
@Index(["eName"], { unique: true })
export class DeviceToken {
@PrimaryGeneratedColumn("uuid")
id!: string;

@Column({ type: "varchar" })
eName!: string;

@Column({ type: "varchar" })
token!: string;

@Column({ type: "varchar" })
platform!: string;

@Column({ type: "varchar" })
deviceId!: string;
@Column({ type: "text", array: true, default: "{}" })
tokens!: string[];

@CreateDateColumn()
createdAt!: Date;
Expand Down
4 changes: 2 additions & 2 deletions infrastructure/evault-core/src/entities/Verification.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,8 +44,8 @@ export class Verification {
@Column({ type: "varchar", nullable: true })
platform!: string;

@Column({ type: "varchar", nullable: true })
fcmToken!: string;
@Column({ type: "text", array: true, default: "{}", nullable: true })
pushTokens!: string[];

@Column({ type: "boolean", default: true })
deviceActive!: boolean;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class Migration1773400000000 implements MigrationInterface {
name = "Migration1773400000000";

public async up(queryRunner: QueryRunner): Promise<void> {
// 1. Restructure device_token: collapse multi-row-per-eName into one-row-per-eName with tokens array

await queryRunner.query(`
CREATE TABLE "device_token_new" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"eName" character varying NOT NULL,
"tokens" text[] NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_device_token_new" PRIMARY KEY ("id")
)
`);

await queryRunner.query(`
INSERT INTO "device_token_new" ("eName", "tokens", "createdAt", "updatedAt")
SELECT
"eName",
array_agg(DISTINCT "token"),
MIN("createdAt"),
MAX("updatedAt")
FROM "device_token"
GROUP BY "eName"
`);

await queryRunner.query(`DROP TABLE "device_token"`);
await queryRunner.query(`ALTER TABLE "device_token_new" RENAME TO "device_token"`);
await queryRunner.query(`ALTER TABLE "device_token" RENAME CONSTRAINT "PK_device_token_new" TO "PK_device_token"`);
await queryRunner.query(`CREATE UNIQUE INDEX "UQ_device_token_ename" ON "device_token" ("eName")`);

// 2. Rename verification.fcmToken -> pushTokens (varchar -> text[])

await queryRunner.query(`ALTER TABLE "verification" ADD "pushTokens" text[] DEFAULT '{}'`);
await queryRunner.query(`
UPDATE "verification"
SET "pushTokens" = CASE
WHEN "fcmToken" IS NOT NULL AND "fcmToken" != '' THEN ARRAY["fcmToken"]
ELSE '{}'
END
`);
await queryRunner.query(`ALTER TABLE "verification" DROP COLUMN "fcmToken"`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
// Revert verification: pushTokens -> fcmToken
await queryRunner.query(`ALTER TABLE "verification" ADD "fcmToken" character varying`);
await queryRunner.query(`
UPDATE "verification"
SET "fcmToken" = CASE
WHEN "pushTokens" IS NOT NULL AND array_length("pushTokens", 1) > 0 THEN "pushTokens"[1]
ELSE NULL
END
`);
await queryRunner.query(`ALTER TABLE "verification" DROP COLUMN "pushTokens"`);

// Revert device_token: expand array rows back into individual rows
await queryRunner.query(`DROP INDEX "UQ_device_token_ename"`);

await queryRunner.query(`
CREATE TABLE "device_token_old" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"eName" character varying NOT NULL,
"token" character varying NOT NULL,
"platform" character varying NOT NULL DEFAULT '',
"deviceId" character varying NOT NULL DEFAULT '',
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_device_token_old" PRIMARY KEY ("id")
)
`);

await queryRunner.query(`
INSERT INTO "device_token_old" ("eName", "token", "createdAt", "updatedAt")
SELECT "eName", unnest("tokens"), "createdAt", "updatedAt"
FROM "device_token"
`);

await queryRunner.query(`DROP TABLE "device_token"`);
await queryRunner.query(`ALTER TABLE "device_token_old" RENAME TO "device_token"`);
await queryRunner.query(`ALTER TABLE "device_token" RENAME CONSTRAINT "PK_device_token_old" TO "PK_device_token"`);
await queryRunner.query(`CREATE UNIQUE INDEX "UQ_device_token_ename_deviceid" ON "device_token" ("eName", "deviceId")`);
await queryRunner.query(`CREATE INDEX "IDX_device_token_ename" ON "device_token" ("eName")`);
Comment on lines +77 to +87

@coderabbitaicoderabbitaiBotMar 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Down migration will fail with unique constraint violation.

When unnesting the tokens array back to individual rows, all rows get deviceId = '' (empty string). If any eName had multiple tokens, creating the unique index UQ_device_token_ename_deviceid on ("eName", "deviceId") will fail because multiple rows will have identical (eName, '') tuples.

Additionally, platform data was lost in the up migration, so all restored rows will have platform = '', which may break application logic expecting valid platform values.

🛠️ Suggested approach

Consider one of these options:

  1. Skip restoring the unique index in down migration if exact rollback fidelity isn't required:
- await queryRunner.query(`CREATE UNIQUE INDEX "UQ_device_token_ename_deviceid" ON "device_token" ("eName", "deviceId")`);+ // Note: Cannot restore original unique constraint as deviceId data was lost in up migration+ await queryRunner.query(`CREATE INDEX "IDX_device_token_ename_deviceid" ON "device_token" ("eName", "deviceId")`);
  1. Preserve platform/deviceId in up migration by storing them in a separate structure or JSONB column if rollback fidelity is important.

  2. Document that this migration is not safely reversible if the data loss is acceptable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@infrastructure/evault-core/src/migrations/1773400000000-MultiDeviceTokens.ts`
around lines 77 - 87, The down migration currently unnests tokens into
"device_token_old" but sets deviceId and platform to empty strings, so creating
the unique index UQ_device_token_ename_deviceid on ("eName","deviceId") will
fail when an eName has multiple rows; to fix, modify the down migration logic
that runs after the INSERT/RENAME (references: the INSERT INTO
"device_token_old" SELECT ... FROM "device_token", the RENAME TO "device_token",
and the CREATE UNIQUE INDEX "UQ_device_token_ename_deviceid") to not create the
unique index (remove or guard the CREATE UNIQUE INDEX
"UQ_device_token_ename_deviceid" statement) OR else preserve real
deviceId/platform during the up migration so they can be restored here; pick one
approach and update the migration accordingly and add a comment that the down
migration is not fully reversible if you choose to skip the index.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we only move forward, shinzou wo sasageyo

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sosweetham — Understood, forward only! 🫡 Acknowledged and noted for future reviews — I won't flag down migration reversibility issues for this project.

(∩ᵒ ᵕ ᵒ∩)
shinzou wo sasageyo


✏️ Learnings added
Learnt from: sosweetham
Repo: MetaState-Prototype-Project/prototype PR: 913
File: infrastructure/evault-core/src/migrations/1773400000000-MultiDeviceTokens.ts:77-87
Timestamp: 2026-03-12T11:03:53.941Z
Learning: The MetaState-Prototype-Project team follows a forward-only migration philosophy. Down migration reversibility is intentionally not a requirement. Do not flag issues about down migrations lacking full rollback fidelity (e.g., lost columns, unrestorable unique indexes) in this repository.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: coodos
Repo: MetaState-Prototype-Project/prototype PR: 691
File: platforms/dreamsync-api/src/database/migrations/1768904445609-migration.ts:6-11
Timestamp: 2026-01-20T10:41:46.622Z
Learning: Guideline: For Wishlist-like data, treat derived fields (e.g., summaryWants, summaryOffers) as non-persistent and regenerable from the canonical content. Ensure migrations and models do not rely on persisting these derived fields; only the core wishlist content should be stored and preserved. In reviews, verify that code paths and DB schemas do not mandate upgrading or snapshotting derived fields, and that any recomputation logic is idempotent and safe to run on startup or migration.

}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
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
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,9 +48,7 @@ export async function sendNotification(request: SendNotificationRequest): Promis
}
}

export async function getDevicesWithTokens(): Promise<
{ token: string; platform: string; eName: string }[]
> {
export async function getDevicesWithTokens(): Promise<{ token: string; eName: string }[]> {
const { env } = await import('$env/dynamic/private');
const provisionerUrl =
env.PUBLIC_PROVISIONER_URL || env.PROVISIONER_URL || 'http://localhost:3001';
Expand All@@ -69,7 +67,7 @@ export async function getDevicesWithTokens(): Promise<

export async function getDevicesByEName(
eName: string
): Promise<{ token: string; platform: string; eName: string }[]> {
): Promise<{ token: string; eName: string }[]> {
const { env } = await import('$env/dynamic/private');
const provisionerUrl =
env.PUBLIC_PROVISIONER_URL || env.PROVISIONER_URL || 'http://localhost:3001';
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@ export interface DeviceRegistration {
eName: string;
deviceId: string;
platform: "android" | "ios" | "desktop";
fcmToken?: string; // For Android/iOS push notifications
pushToken?: string;
registrationTime: Date;
}

Expand DownExpand Up@@ -76,17 +76,16 @@ class NotificationService {
throw new Error("Notification permissions not granted");
}

// Get FCM token for mobile platforms
let fcmToken: string | undefined;
let pushToken: string | undefined;
if (platform === "android" || platform === "ios") {
fcmToken = await this.getFCMToken();
pushToken = await this.getPushNotificationToken();
}

const registration: DeviceRegistration = {
eName,
deviceId,
platform,
fcmToken,
pushToken,
registrationTime: new Date(),
};

Expand DownExpand Up@@ -173,6 +172,7 @@ class NotificationService {
body: JSON.stringify({
eName: this.deviceRegistration.eName,
deviceId: this.deviceRegistration.deviceId,
pushToken: this.deviceRegistration.pushToken,
}),
},
);
Expand DownExpand Up@@ -342,9 +342,9 @@ class NotificationService {
}

/**
* Get push notification token (FCM on Android, APNs on iOS)
* Get push notification token from the platform (FCM on Android, APNs on iOS).
*/
private async getFCMToken(): Promise<string | undefined> {
private async getPushNotificationToken(): Promise<string | undefined> {
try {
return await registerForPushNotifications();
} catch (error) {
Expand All@@ -354,15 +354,15 @@ class NotificationService {
}

/**
* Request permissions and get push notification token (FCM on Android, APNs on iOS).
* Request permissions and get push notification token.
* Returns undefined on desktop or if permission is denied.
*/
async getPushToken(): Promise<string | undefined> {
const hasPermission = await this.requestPermissions();
if (!hasPermission) return undefined;
const platform = await this.getPlatform();
if (platform !== "android" && platform !== "ios") return undefined;
return this.getFCMToken();
return this.getPushNotificationToken();
}
/**
* Get eName from vault (helper method)
Expand Down
28 changes: 24 additions & 4 deletions infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte
Original file line numberDiff line numberDiff line change
Expand Up@@ -96,12 +96,22 @@ onMount(async () => {
);
} catch (error) {
console.error("Error syncing public key:", error);
// Continue to app even if sync fails - non-blocking
}

// Register device for push notifications on login
try {
await globalState.notificationService.registerDevice(
vault.ename,
);
} catch (error) {
console.error(
"Error registering device for notifications:",
error,
);
}
}
} catch (error) {
console.error("Error during eVault health check:", error);
// Continue to app even if health check fails - non-blocking
}

// Check if there's a pending deep link to process
Expand DownExpand Up@@ -176,12 +186,22 @@ onMount(async () => {
);
} catch (error) {
console.error("Error syncing public key:", error);
// Continue to app even if sync fails - non-blocking
}

// Register device for push notifications on biometric login
try {
await globalState.notificationService.registerDevice(
vault.ename,
);
} catch (error) {
console.error(
"Error registering device for notifications:",
error,
);
}
}
} catch (error) {
console.error("Error during eVault health check:", error);
// Continue to app even if health check fails - non-blocking
}

// Check if there's a pending deep link to process
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,7 +65,7 @@ export class NotificationController {

private async registerDevice(req: Request, res: Response) {
try {
const { eName, deviceId, platform, fcmToken } = req.body;
const { eName, deviceId, platform, pushToken } = req.body;

if (!eName || !deviceId || !platform) {
return res.status(400).json({
Expand All@@ -74,20 +74,17 @@ export class NotificationController {
});
}

if (fcmToken && typeof fcmToken === "string" && fcmToken.trim()) {
await this.deviceTokenService.register({
eName,
deviceId,
platform,
token: fcmToken.trim(),
});
const token = typeof pushToken === "string" ? pushToken.trim() : undefined;

if (token) {
await this.deviceTokenService.register(eName, token);
}

const verification = await this.notificationService.registerDevice({
eName,
deviceId,
platform,
fcmToken: fcmToken.trim(),
pushToken: token,
registrationTime: new Date(),
});

Expand All@@ -107,7 +104,7 @@ export class NotificationController {

private async unregisterDevice(req: Request, res: Response) {
try {
const { eName, deviceId } = req.body;
const { eName, deviceId, pushToken } = req.body;

if (!eName || !deviceId) {
return res.status(400).json({
Expand All@@ -116,7 +113,11 @@ export class NotificationController {
});
}

await this.deviceTokenService.unregister(eName, deviceId);
const token = typeof pushToken === "string" ? pushToken.trim() : undefined;
if (token) {
await this.deviceTokenService.unregister(eName, token);
}

const success = await this.notificationService.unregisterDevice(eName, deviceId);

res.json({
Expand Down
12 changes: 3 additions & 9 deletions infrastructure/evault-core/src/entities/DeviceToken.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,22 +8,16 @@ import {
} from "typeorm";

@Entity("device_token")
@Index(["eName", "deviceId"], { unique: true })
@Index(["eName"], { unique: true })
export class DeviceToken {
@PrimaryGeneratedColumn("uuid")
id!: string;

@Column({ type: "varchar" })
eName!: string;

@Column({ type: "varchar" })
token!: string;

@Column({ type: "varchar" })
platform!: string;

@Column({ type: "varchar" })
deviceId!: string;
@Column({ type: "text", array: true, default: "{}" })
tokens!: string[];

@CreateDateColumn()
createdAt!: Date;
Expand Down
4 changes: 2 additions & 2 deletions infrastructure/evault-core/src/entities/Verification.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,8 +44,8 @@ export class Verification {
@Column({ type: "varchar", nullable: true })
platform!: string;

@Column({ type: "varchar", nullable: true })
fcmToken!: string;
@Column({ type: "text", array: true, default: "{}", nullable: true })
pushTokens!: string[];

@Column({ type: "boolean", default: true })
deviceActive!: boolean;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class Migration1773400000000 implements MigrationInterface {
name = "Migration1773400000000";

public async up(queryRunner: QueryRunner): Promise<void> {
// 1. Restructure device_token: collapse multi-row-per-eName into one-row-per-eName with tokens array

await queryRunner.query(`
CREATE TABLE "device_token_new" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"eName" character varying NOT NULL,
"tokens" text[] NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_device_token_new" PRIMARY KEY ("id")
)
`);

await queryRunner.query(`
INSERT INTO "device_token_new" ("eName", "tokens", "createdAt", "updatedAt")
SELECT
"eName",
array_agg(DISTINCT "token"),
MIN("createdAt"),
MAX("updatedAt")
FROM "device_token"
GROUP BY "eName"
`);

await queryRunner.query(`DROP TABLE "device_token"`);
await queryRunner.query(`ALTER TABLE "device_token_new" RENAME TO "device_token"`);
await queryRunner.query(`ALTER TABLE "device_token" RENAME CONSTRAINT "PK_device_token_new" TO "PK_device_token"`);
await queryRunner.query(`CREATE UNIQUE INDEX "UQ_device_token_ename" ON "device_token" ("eName")`);

// 2. Rename verification.fcmToken -> pushTokens (varchar -> text[])

await queryRunner.query(`ALTER TABLE "verification" ADD "pushTokens" text[] DEFAULT '{}'`);
await queryRunner.query(`
UPDATE "verification"
SET "pushTokens" = CASE
WHEN "fcmToken" IS NOT NULL AND "fcmToken" != '' THEN ARRAY["fcmToken"]
ELSE '{}'
END
`);
await queryRunner.query(`ALTER TABLE "verification" DROP COLUMN "fcmToken"`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
// Revert verification: pushTokens -> fcmToken
await queryRunner.query(`ALTER TABLE "verification" ADD "fcmToken" character varying`);
await queryRunner.query(`
UPDATE "verification"
SET "fcmToken" = CASE
WHEN "pushTokens" IS NOT NULL AND array_length("pushTokens", 1) > 0 THEN "pushTokens"[1]
ELSE NULL
END
`);
await queryRunner.query(`ALTER TABLE "verification" DROP COLUMN "pushTokens"`);

// Revert device_token: expand array rows back into individual rows
await queryRunner.query(`DROP INDEX "UQ_device_token_ename"`);

await queryRunner.query(`
CREATE TABLE "device_token_old" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"eName" character varying NOT NULL,
"token" character varying NOT NULL,
"platform" character varying NOT NULL DEFAULT '',
"deviceId" character varying NOT NULL DEFAULT '',
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_device_token_old" PRIMARY KEY ("id")
)
`);

await queryRunner.query(`
INSERT INTO "device_token_old" ("eName", "token", "createdAt", "updatedAt")
SELECT "eName", unnest("tokens"), "createdAt", "updatedAt"
FROM "device_token"
`);

await queryRunner.query(`DROP TABLE "device_token"`);
await queryRunner.query(`ALTER TABLE "device_token_old" RENAME TO "device_token"`);
await queryRunner.query(`ALTER TABLE "device_token" RENAME CONSTRAINT "PK_device_token_old" TO "PK_device_token"`);
await queryRunner.query(`CREATE UNIQUE INDEX "UQ_device_token_ename_deviceid" ON "device_token" ("eName", "deviceId")`);
await queryRunner.query(`CREATE INDEX "IDX_device_token_ename" ON "device_token" ("eName")`);
Comment on lines +77 to +87

@coderabbitaicoderabbitaiBotMar 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Down migration will fail with unique constraint violation.

When unnesting the tokens array back to individual rows, all rows get deviceId = '' (empty string). If any eName had multiple tokens, creating the unique index UQ_device_token_ename_deviceid on ("eName", "deviceId") will fail because multiple rows will have identical (eName, '') tuples.

Additionally, platform data was lost in the up migration, so all restored rows will have platform = '', which may break application logic expecting valid platform values.

🛠️ Suggested approach

Consider one of these options:

  1. Skip restoring the unique index in down migration if exact rollback fidelity isn't required:
- await queryRunner.query(`CREATE UNIQUE INDEX "UQ_device_token_ename_deviceid" ON "device_token" ("eName", "deviceId")`);+ // Note: Cannot restore original unique constraint as deviceId data was lost in up migration+ await queryRunner.query(`CREATE INDEX "IDX_device_token_ename_deviceid" ON "device_token" ("eName", "deviceId")`);
  1. Preserve platform/deviceId in up migration by storing them in a separate structure or JSONB column if rollback fidelity is important.

  2. Document that this migration is not safely reversible if the data loss is acceptable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@infrastructure/evault-core/src/migrations/1773400000000-MultiDeviceTokens.ts`
around lines 77 - 87, The down migration currently unnests tokens into
"device_token_old" but sets deviceId and platform to empty strings, so creating
the unique index UQ_device_token_ename_deviceid on ("eName","deviceId") will
fail when an eName has multiple rows; to fix, modify the down migration logic
that runs after the INSERT/RENAME (references: the INSERT INTO
"device_token_old" SELECT ... FROM "device_token", the RENAME TO "device_token",
and the CREATE UNIQUE INDEX "UQ_device_token_ename_deviceid") to not create the
unique index (remove or guard the CREATE UNIQUE INDEX
"UQ_device_token_ename_deviceid" statement) OR else preserve real
deviceId/platform during the up migration so they can be restored here; pick one
approach and update the migration accordingly and add a comment that the down
migration is not fully reversible if you choose to skip the index.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we only move forward, shinzou wo sasageyo

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sosweetham — Understood, forward only! 🫡 Acknowledged and noted for future reviews — I won't flag down migration reversibility issues for this project.

(∩ᵒ ᵕ ᵒ∩)
shinzou wo sasageyo


✏️ Learnings added
Learnt from: sosweetham
Repo: MetaState-Prototype-Project/prototype PR: 913
File: infrastructure/evault-core/src/migrations/1773400000000-MultiDeviceTokens.ts:77-87
Timestamp: 2026-03-12T11:03:53.941Z
Learning: The MetaState-Prototype-Project team follows a forward-only migration philosophy. Down migration reversibility is intentionally not a requirement. Do not flag issues about down migrations lacking full rollback fidelity (e.g., lost columns, unrestorable unique indexes) in this repository.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: coodos
Repo: MetaState-Prototype-Project/prototype PR: 691
File: platforms/dreamsync-api/src/database/migrations/1768904445609-migration.ts:6-11
Timestamp: 2026-01-20T10:41:46.622Z
Learning: Guideline: For Wishlist-like data, treat derived fields (e.g., summaryWants, summaryOffers) as non-persistent and regenerable from the canonical content. Ensure migrations and models do not rely on persisting these derived fields; only the core wishlist content should be stored and preserved. In reviews, verify that code paths and DB schemas do not mandate upgrading or snapshotting derived fields, and that any recomputation logic is idempotent and safe to run on startup or migration.

}
}
Loading
Loading