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
5 changes: 4 additions & 1 deletion public/sw.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
const CACHE_NAME = 'teachlink-cache-v1';
// Bump CACHE_VERSION on deploy so stale assets are busted: the activate
// handler below removes caches from older versions.
const CACHE_VERSION = 2;
const CACHE_NAME = 'teachlink-cache-v' + CACHE_VERSION;
const OFFLINE_URL = '/offline.html';

const URLS_TO_CACHE = [
Expand Down
3 changes: 2 additions & 1 deletion src/lib/settings/__tests__/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type AppSettings,
type SettingsStorePersistedShape,
} from '../types';
import { SETTINGS_SCHEMA_VERSION } from '../constants';

// ─── Helpers ──────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -416,7 +417,7 @@ describe('SettingsService', () => {

it('rejects data with missing settings', () => {
const invalidData = {
version: 1,
version: SETTINGS_SCHEMA_VERSION,
exportedAt: new Date().toISOString(),
updatedAt: Date.now(),
};
Expand Down
13 changes: 12 additions & 1 deletion src/lib/settings/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ export class SettingsService {
canEditReducedMotion: boolean;
canEditElectronicSignature: boolean;
canEditPollSettings: boolean;
canEditVirtualBackground: boolean;
canExportSettings: boolean;
canImportSettings: boolean;
canSyncSettings: boolean;
Expand All @@ -255,6 +256,7 @@ export class SettingsService {
canEditReducedMotion: true,
canEditElectronicSignature: true,
canEditPollSettings: true,
canEditVirtualBackground: true,
canExportSettings: true,
canImportSettings: true,
canSyncSettings: true,
Expand All @@ -278,6 +280,11 @@ export class SettingsService {
electronicSignatureEnabled: 'canEditElectronicSignature',
signatureName: 'canEditElectronicSignature',
requireSignatureOnCertificates: 'canEditElectronicSignature',
virtualBackgroundEnabled: 'canEditVirtualBackground',
virtualBackgroundType: 'canEditVirtualBackground',
virtualBackgroundImage: 'canEditVirtualBackground',
virtualBackgroundBlur: 'canEditVirtualBackground',
virtualBackgroundColor: 'canEditVirtualBackground',
pollCreationEnabled: 'canEditPollSettings',
defaultPollDuration: 'canEditPollSettings',
allowAnonymousVoting: 'canEditPollSettings',
Expand All @@ -293,7 +300,7 @@ export class SettingsService {
static getDocumentationMetadata(): DocumentationMetadata {
return {
version: SETTINGS_DOCUMENTATION_VERSION,
lastUpdated: '2025-05-30',
lastUpdated: '2026-08-28',
schemaVersion: SETTINGS_SCHEMA_VERSION,
fields: {
version: 'Schema version for settings structure',
Expand All @@ -311,6 +318,10 @@ export class SettingsService {
virtualBackgroundImage: 'Custom background image URL',
virtualBackgroundBlur: 'Blur intensity (0-100)',
virtualBackgroundColor: 'Hex color for solid background',
pollCreationEnabled: 'Master toggle for creating interactive polls',
defaultPollDuration: 'Default poll duration in days (1-30)',
allowAnonymousVoting: 'Allow anonymous voting by default',
pollResultsVisibility: "Default poll results visibility ('always' | 'after_voting' | 'after_ended')",
},
};
}
Expand Down
15 changes: 15 additions & 0 deletions src/lib/settings/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export type VirtualBackgroundType = z.infer<typeof virtualBackgroundTypeSchema>;
* - `electronicSignatureEnabled` — Master toggle for electronic signature on authenticated actions.
* - `signatureName` — Full name used as the typed electronic signature (max 100 chars).
* - `requireSignatureOnCertificates` — Prompt the user to confirm their signature before a certificate is issued.
* - `virtualBackgroundEnabled` — Master toggle for virtual background in video calls.
* - `virtualBackgroundType` — Type of virtual background: `'none'`, `'blur'`, `'image'`, or `'color'`.
* - `virtualBackgroundImage` — URL or data URI for custom background image (max 500 chars).
* - `virtualBackgroundBlur` — Blur intensity for background (0-100).
* - `virtualBackgroundColor` — Hex color for solid color background (max 7 chars, e.g. '#RRGGBB').
* - `pollCreationEnabled` — Master toggle for creating interactive polls in classes or study groups.
* - `defaultPollDuration` — Default poll duration in days (1 to 30 days).
* - `allowAnonymousVoting` — Toggle to allow participants to vote anonymously by default.
Expand All @@ -38,6 +43,11 @@ export const appSettingsSchema = z.object({
electronicSignatureEnabled: z.boolean(),
signatureName: z.string().max(100),
requireSignatureOnCertificates: z.boolean(),
virtualBackgroundEnabled: z.boolean(),
virtualBackgroundType: virtualBackgroundTypeSchema,
virtualBackgroundImage: z.string().max(500),
virtualBackgroundBlur: z.number().min(0).max(100),
virtualBackgroundColor: z.string().max(7),
pollCreationEnabled: z.boolean(),
defaultPollDuration: z.number().int().min(1).max(30),
allowAnonymousVoting: z.boolean(),
Expand Down Expand Up @@ -95,6 +105,11 @@ export function createDefaultSettings(): AppSettings {
electronicSignatureEnabled: false,
signatureName: '',
requireSignatureOnCertificates: false,
virtualBackgroundEnabled: false,
virtualBackgroundType: 'none',
virtualBackgroundImage: '',
virtualBackgroundBlur: 10,
virtualBackgroundColor: '#000000',
pollCreationEnabled: true,
defaultPollDuration: 7,
allowAnonymousVoting: false,
Expand Down
32 changes: 20 additions & 12 deletions src/serviceWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate, NetworkFirst, CacheFirst, NetworkOnly } from 'workbox-strategies';
import { BackgroundSyncPlugin } from 'workbox-background-sync';
import { isObsoleteCacheName, versionedCacheName } from '@/utils/swCacheVersion';

declare const self: ServiceWorkerGlobalScope;

Expand Down Expand Up @@ -33,12 +34,12 @@ registerRoute(
try {
const response = await fetch(offlineFallbackPage);
if (response) {
const cache = await caches.open('offline-fallback');
const cache = await caches.open(versionedCacheName('offline-fallback'));
await cache.put(offlineFallbackPage, response.clone());
return response;
}
} catch {
const cache = await caches.open('offline-fallback');
const cache = await caches.open(versionedCacheName('offline-fallback'));
const cachedResponse = await cache.match(offlineFallbackPage);
if (cachedResponse) return cachedResponse;
}
Expand All @@ -50,7 +51,7 @@ registerRoute(
registerRoute(
({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.js'),
new StaleWhileRevalidate({
cacheName: 'static-js',
cacheName: versionedCacheName('static-js'),
plugins: [
new ExpirationPlugin({
maxEntries: 50,
Expand All @@ -63,7 +64,7 @@ registerRoute(
registerRoute(
({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.css'),
new StaleWhileRevalidate({
cacheName: 'static-css',
cacheName: versionedCacheName('static-css'),
plugins: [
new ExpirationPlugin({
maxEntries: 50,
Expand All @@ -77,7 +78,7 @@ registerRoute(
registerRoute(
({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.png'),
new CacheFirst({
cacheName: 'images',
cacheName: versionedCacheName('images'),
plugins: [new ExpirationPlugin({ maxEntries: 50 })],
}),
);
Expand All @@ -86,7 +87,7 @@ registerRoute(
({ url }) =>
url.origin === self.location.origin && url.pathname.match(/\.(jpg|jpeg|svg|gif|webp)$/),
new CacheFirst({
cacheName: 'images-ext',
cacheName: versionedCacheName('images-ext'),
plugins: [new ExpirationPlugin({ maxEntries: 100 })],
}),
);
Expand All @@ -98,7 +99,7 @@ registerRoute(
url.hostname === 'thumbs.dreamstime.com' ||
url.hostname === 'static.vecteezy.com',
new StaleWhileRevalidate({
cacheName: 'external-images',
cacheName: versionedCacheName('external-images'),
plugins: [
new ExpirationPlugin({
maxEntries: 100,
Expand Down Expand Up @@ -213,7 +214,7 @@ registerRoute(
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-responses',
cacheName: versionedCacheName('api-responses'),
plugins: [
new ExpirationPlugin({
maxEntries: 50,
Expand All @@ -227,7 +228,7 @@ registerRoute(
registerRoute(
({ url }) => url.pathname.match(/\.(woff2?|ttf|otf|eot)$/),
new CacheFirst({
cacheName: 'fonts',
cacheName: versionedCacheName('fonts'),
plugins: [
new ExpirationPlugin({
maxEntries: 20,
Expand All @@ -254,8 +255,15 @@ self.addEventListener('message', (event) => {

self.addEventListener('activate', (event) => {
event.waitUntil(
Promise.resolve().then(() => {
clientsClaim();
}),
caches
.keys()
.then((keys) =>
Promise.all(
keys.filter((key) => isObsoleteCacheName(key)).map((key) => caches.delete(key)),
),
)
.then(() => {
clientsClaim();
}),
);
});
41 changes: 41 additions & 0 deletions src/utils/__tests__/swCacheVersion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import {
SW_CACHE_VERSION,
isObsoleteCacheName,
versionedCacheName,
} from '@/utils/swCacheVersion';

describe('versionedCacheName', () => {
it('namespaces runtime cache names with the current version', () => {
expect(versionedCacheName('static-js')).toBe(`${SW_CACHE_VERSION}::static-js`);
expect(versionedCacheName('api-responses')).toBe(`${SW_CACHE_VERSION}::api-responses`);
});
});

describe('isObsoleteCacheName', () => {
it('keeps caches created by the current version', () => {
expect(isObsoleteCacheName(`${SW_CACHE_VERSION}::static-js`)).toBe(false);
expect(isObsoleteCacheName(`${SW_CACHE_VERSION}::fonts`)).toBe(false);
});

it('flags caches created by an older service-worker version', () => {
expect(isObsoleteCacheName('v1::static-js')).toBe(true);
expect(isObsoleteCacheName('v1::api-responses')).toBe(true);
});

it('flags legacy unversioned caches from before versioning', () => {
expect(isObsoleteCacheName('static-js')).toBe(true);
expect(isObsoleteCacheName('offline-fallback')).toBe(true);
expect(isObsoleteCacheName('fonts')).toBe(true);
});

it('leaves workbox-managed caches untouched', () => {
expect(isObsoleteCacheName('workbox-precache-v2-abc123')).toBe(false);
expect(isObsoleteCacheName('workbox-background-sync')).toBe(false);
});

it('respects an explicit current version', () => {
expect(isObsoleteCacheName('v3::static-js', 'v3')).toBe(false);
expect(isObsoleteCacheName('v2::static-js', 'v3')).toBe(true);
});
});
5 changes: 4 additions & 1 deletion src/utils/registerSW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export type UpdateCallback = (registration: ServiceWorkerRegistration) => void;
*/

import { createLogger } from '@/lib/logging';
import { SW_CACHE_VERSION } from './swCacheVersion';

const logger = createLogger('service-worker');
export async function registerSW(
Expand All @@ -16,6 +17,8 @@ export async function registerSW(
try {
const registration = await navigator.serviceWorker.register('/sw.js');

logger.info('[SW] Registered', { cacheVersion: SW_CACHE_VERSION });

const checkForWaiting = (reg: ServiceWorkerRegistration) => {
if (reg.waiting) {
onUpdate?.(reg);
Expand Down Expand Up @@ -43,7 +46,7 @@ export async function registerSW(

return registration;
} catch (err) {
logger.error('[SW] Registration failed', { error: err });
logger.error('[SW] Registration failed', { error: err, cacheVersion: SW_CACHE_VERSION });
return null;
}
}
Expand Down
45 changes: 45 additions & 0 deletions src/utils/swCacheVersion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* Shared service-worker cache versioning.
*
* Bump `SW_CACHE_VERSION` whenever the app shell or cached assets change so
* that the installed service worker starts writing to a fresh cache namespace
* and purges caches created by older versions during the activate lifecycle.
* This prevents stale assets from persisting after a deploy.
*/

export const SW_CACHE_VERSION = 'v2';

/** Namespace a runtime cache name with the current service-worker version. */
export const versionedCacheName = (name: string): string => `${SW_CACHE_VERSION}::${name}`;

/**
* Runtime cache names that predate versioning. They are cleaned up once so the
* upgrade to versioned namespaces is not left with orphaned stale assets.
*/
const LEGACY_UNVERSIONED_CACHES = [
'offline-fallback',
'static-js',
'static-css',
'images',
'images-ext',
'external-images',
'api-responses',
'fonts',
] as const;

/**
* True when a cache should be deleted during the activate lifecycle:
* - caches created by an older service-worker version (e.g. `v1::static-js`),
* - legacy unversioned caches from before versioning was introduced.
*
* Workbox-managed caches (e.g. `workbox-precache-v2-...`) are left untouched.
*/
export const isObsoleteCacheName = (
name: string,
currentVersion: string = SW_CACHE_VERSION,
): boolean => {
if (name.startsWith(`${currentVersion}::`)) return false;
if ((LEGACY_UNVERSIONED_CACHES as readonly string[]).includes(name)) return true;
// Versioned namespace from an older service-worker version.
return /^v\d+::/.test(name);
};
Loading