Skip to content

Feature Request: Idempotent Overlay Creation #1190

@chriselsen

Description

@chriselsen

Summary

When a user attempts to add a PMTiles file that's already in their overlays, the application throws a database constraint error instead of gracefully handling the duplicate. This creates a poor user experience, especially when overlays are hidden and users want to re-add them.

Current Behavior

  1. User clicks to add a PMTiles file that already exists in their overlays
  2. Backend throws: Error: Key (username, url)=(user@example.com, /api/profile/asset/xxx.pmtiles/tile) already exists
  3. Frontend displays error to user
  4. User must manually unhide the overlay from the overlays panel

Expected Behavior

  1. User clicks to add a PMTiles file that already exists
  2. Backend detects duplicate and unhides the existing overlay
  3. Frontend receives the existing overlay and displays it
  4. Operation completes silently without errors (idempotent)

Impact

  • Poor UX when users hide overlays and later want to re-add them
  • Confusing error messages about database constraints
  • Forces users to understand the difference between "hidden" and "deleted"

Proposed Solution

Backend Changes

File: api/routes/profile-overlays.ts

Location: POST /profile/overlay endpoint (around line 240)

Add duplicate detection before attempting to insert:

await schema.post('/profile/overlay', {
    name: 'Create Overlay',
    group: 'ProfileOverlay',
    description: 'Create Profile Overlay',
    body: Type.Object({
        name: Type.String(),
        active: Type.Optional(Type.Boolean()),
        pos: Type.Optional(Type.Integer()),
        type: Type.Optional(Type.String()),
        opacity: Type.Optional(Type.Number()),
        frequency: Type.Optional(Type.Union([Type.Null(), Type.Number()])),
        iconset: Type.Optional(Type.Union([Type.Null(), Type.String()])),
        visible: Type.Optional(Type.Boolean()),
        mode: Type.String(),
        mode_id: Type.Optional(Type.String()),
        styles: Type.Optional(Type.Array(Type.Unknown())),
        token: Type.Optional(Type.String()),
        url: Type.String(),
    }),
    res: AugmentedProfileOverlayResponse
}, async (req, res) => {
    let user;
    try {
        user = await Auth.as_user(config, req);

        if (req.body.styles && req.body.styles.length) {
            TileJSON.isValidStyle(req.body.type || 'raster', req.body.styles);
        }

        // Normalize URL
        if (req.body.mode === 'profile' && req.body.url.startsWith('http')) {
            const url = new URL(req.body.url);
            req.body.url = url.pathname;
        }

        // Check if overlay already exists and unhide it
        const existing = await config.models.ProfileOverlay.list({
            limit: 1,
            where: sql`username = ${user.email} AND url = ${req.body.url}`
        });

        if (existing.total > 0) {
            const overlay = await config.models.ProfileOverlay.commit(existing.items[0].id, {
                visible: true
            });

            if (overlay.mode === 'basemap' || overlay.mode === 'overlay') {
                const basemap = await config.models.Basemap.from(overlay.mode_id);
                return res.json({
                    ...overlay,
                    actions: TileJSON.actions(basemap.url),
                    opacity: Number(overlay.opacity)
                });
            } else {
                return res.json({
                    ...overlay,
                    actions: TileJSON.actions(),
                    opacity: Number(overlay.opacity)
                });
            }
        }

        if (req.body.active && req.body.mode !== 'mission') {
            throw new Err(400, null, 'Only mission overlays can be made active');
        } else if (req.body.active) {
            await config.models.ProfileOverlay.commit(sql`
                username = ${user.email}
            `, {
                active: false
            });
        }

        let overlay;
        if (req.body.mode === 'mission') {
            if (!req.body.mode_id) throw new Err(400, null, 'Mode: Mission must have mode_id set');

            const profile = await config.models.Profile.from(user.email);
            const api = await TAKAPI.init(new URL(String(config.server.api)), new APIAuthCertificate(profile.auth.cert, profile.auth.key));

            const sub = await api.Mission.subscribe(req.body.mode_id, {
                uid: `ANDROID-CloudTAK-${user.email}`
            }, {
                token: req.body.token
            });

            overlay = await config.models.ProfileOverlay.generate({
                ...req.body,
                opacity: String(req.body.opacity || 1),
                username: user.email,
                token: sub.data.token
            })
        } else {
            overlay = await config.models.ProfileOverlay.generate({
                ...req.body,
                opacity: String(req.body.opacity || 1),
                username: user.email
            });
        }

        if (overlay.mode === 'basemap' || overlay.mode === 'overlay') {
            const basemap = await config.models.Basemap.from(overlay.mode_id);

            res.json({
                ...overlay,
                actions: TileJSON.actions(basemap.url),
                opacity: Number(overlay.opacity)
            });
        } else {
            res.json({
                ...overlay,
                actions: TileJSON.actions(),
                opacity: Number(overlay.opacity)
            });
        }
    } catch (err) {
        return Err.respond(err, res);
    }
});

Key Changes:

  1. Move URL normalization before duplicate check (line 267-271)
  2. Add duplicate detection query (line 273-277)
  3. If duplicate found, unhide and return existing overlay (line 279-296)
  4. Remove redundant URL normalization from else block (was line 330-333)

Frontend Changes

File: api/web/src/base/overlay.ts

Location: async init() method (around line 220)

Add source existence checks before adding to map:

async init(opts: {
    clickable?: Array<{ id: string; type: string }>;
    before?: string;
} = {}) {
    const mapStore = useMapStore();

    if (this.type === 'raster' && this.url) {
        const url = stdurl(this.url);
        url.searchParams.append('token', localStorage.token);

        const tileJSON = await std(url.toString()) as TileJSON

        if (!mapStore.map.getSource(String(this.id))) {
            mapStore.map.addSource(String(this.id), {
                ...tileJSON,
                type: 'raster',
            });
        }
    } else if (this.type === 'vector' && this.url) {
        const url = stdurl(this.url);
        url.searchParams.append('token', localStorage.token);

        if (!mapStore.map.getSource(String(this.id))) {
            mapStore.map.addSource(String(this.id), {
                type: 'vector',
                url: String(url)
            });
        }
    } else if (this.type === 'geojson') {
        if (!mapStore.map.getSource(String(this.id))) {
            const data: FeatureCollection = { type: 'FeatureCollection', features: [] };

            mapStore.map.addSource(String(this.id), {
                type: 'geojson',
                cluster: false,
                data
            })
        }
    }
    
    // ... rest of method
}

Key Changes:

  1. Add getSource() check before addSource() for raster type (line 233)
  2. Add getSource() check before addSource() for vector type (line 242)
  3. Geojson type already had this check

Benefits

  • Better UX: No error messages when re-adding overlays
  • Idempotent API: Same operation can be repeated safely
  • Intuitive behavior: "Add overlay" works whether it exists or not
  • Consistent with hide/show pattern: Users can hide overlays and easily re-add them

Testing

  1. Add a PMTiles file to overlays
  2. Hide the overlay (don't delete)
  3. Click to add the same PMTiles file again
  4. Verify overlay becomes visible without errors
  5. Verify no duplicate sources in map
  6. Verify database has only one overlay record

Database Constraint

The unique constraint on (username, url) in the profile_overlays table remains unchanged and continues to prevent actual duplicates at the database level.

export const ProfileOverlay = pgTable('profile_overlays', {
    // ... fields
}, (t) => ({
    unq: unique().on(t.username, t.url)
}));

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestweb-frontendContainer for all web frontend issues

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions