-
-
Notifications
You must be signed in to change notification settings - Fork 16
Open
Labels
enhancementNew feature or requestNew feature or requestweb-frontendContainer for all web frontend issuesContainer for all web frontend issues
Description
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
- User clicks to add a PMTiles file that already exists in their overlays
- Backend throws:
Error: Key (username, url)=(user@example.com, /api/profile/asset/xxx.pmtiles/tile) already exists - Frontend displays error to user
- User must manually unhide the overlay from the overlays panel
Expected Behavior
- User clicks to add a PMTiles file that already exists
- Backend detects duplicate and unhides the existing overlay
- Frontend receives the existing overlay and displays it
- 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:
- Move URL normalization before duplicate check (line 267-271)
- Add duplicate detection query (line 273-277)
- If duplicate found, unhide and return existing overlay (line 279-296)
- 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:
- Add
getSource()check beforeaddSource()for raster type (line 233) - Add
getSource()check beforeaddSource()for vector type (line 242) - 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
- Add a PMTiles file to overlays
- Hide the overlay (don't delete)
- Click to add the same PMTiles file again
- Verify overlay becomes visible without errors
- Verify no duplicate sources in map
- 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)
}));Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
enhancementNew feature or requestNew feature or requestweb-frontendContainer for all web frontend issuesContainer for all web frontend issues