Overview
The Locations API manages quest locations, tracks user progress at each location, and handles photo uploads for location completion. All endpoints require authentication.
Queries
listByQuest
Retrieves all locations for a specific quest, ordered by their sequence.
import { api } from "@/convex/_generated/api";
import { useQuery } from "convex/react";
const locations = useQuery(api.locations.listByQuest, { questId });
The unique identifier of the quest
Array of locations ordered by the order field
Location Object Structure:
Unique location identifier
The quest this location belongs to
Detailed location description
coordinates
{ latitude: number, longitude: number }
GPS coordinates of the location
Sequence order of the location in the quest
Example Response:
[
{
_id: "location123",
questId: "quest456",
name: "Brandenburg Gate",
description: "Visit the iconic Brandenburg Gate",
coordinates: {
latitude: 52.5163,
longitude: 13.3777
},
order: 1
},
{
_id: "location124",
questId: "quest456",
name: "Reichstag Building",
description: "Explore the historic parliament building",
coordinates: {
latitude: 52.5186,
longitude: 13.3761
},
order: 2
}
]
Implementation:
return await ctx.db
.query("locations")
.withIndex("by_quest_order", (q) => q.eq("questId", questId))
.collect();
Errors:
- “Quest not found” - The specified quest ID doesn’t exist
- “Not authenticated” - User is not logged in
get
Retrieves a single location by ID.
import { api } from "@/convex/_generated/api";
import { useQuery } from "convex/react";
const location = useQuery(api.locations.get, { locationId });
The unique identifier of the location
The location object with all fields
Errors:
- “Location not found” - The specified location ID doesn’t exist
- “Not authenticated” - User is not logged in
listCompleted
Returns all locations the user has completed for a specific quest.
import { api } from "@/convex/_generated/api";
import { useQuery } from "convex/react";
const completedLocations = useQuery(api.locations.listCompleted, { questId });
The quest ID to get completed locations for
Array of user location completion records
UserLocation Object:
Unique user location record identifier
Reference to the uploaded photo in Convex storage
Unix timestamp when the location was completed
Example Response:
[
{
_id: "userLocation123",
userId: "user456",
questId: "quest789",
locationId: "location101",
photoStorageId: "storage_abc123",
completedAt: 1709481600000
}
]
Errors:
- “Not authenticated” - User is not logged in
Mutations
generateUploadUrl
Generates a temporary URL for uploading a photo to Convex storage. This must be called before uploading a photo for location completion.
import { api } from "@/convex/_generated/api";
import { useMutation } from "convex/react";
const generateUploadUrl = useMutation(api.locations.generateUploadUrl);
const uploadUrl = await generateUploadUrl();
Temporary URL for uploading files to Convex storage
Usage Example:
import { api } from "@/convex/_generated/api";
import { useMutation } from "convex/react";
function PhotoUpload() {
const generateUploadUrl = useMutation(api.locations.generateUploadUrl);
const completeLocation = useMutation(api.locations.complete);
const handlePhotoUpload = async (photoUri: string) => {
// Step 1: Generate upload URL
const uploadUrl = await generateUploadUrl();
// Step 2: Upload the photo
const response = await fetch(photoUri);
const blob = await response.blob();
const uploadResponse = await fetch(uploadUrl, {
method: "POST",
body: blob,
headers: { "Content-Type": blob.type },
});
const { storageId } = await uploadResponse.json();
// Step 3: Complete the location with the storage ID
await completeLocation({
questId,
locationId,
photoStorageId: storageId,
});
};
return <CameraComponent onCapture={handlePhotoUpload} />;
}
Errors:
- “Not authenticated” - User is not logged in
complete
Marks a location as completed for the current user. Requires uploading a photo first.
import { api } from "@/convex/_generated/api";
import { useMutation } from "convex/react";
const completeLocation = useMutation(api.locations.complete);
await completeLocation({ questId, locationId, photoStorageId });
The ID of the quest this location belongs to
The ID of the location to complete
The storage ID of the uploaded photo (obtained from the upload response)
The ID of the newly created user location record
Validation Logic:
The mutation performs comprehensive validation before marking a location as complete:
// 1. Verify quest is started
const userQuest = await ctx.db
.query("userQuests")
.withIndex("by_user_and_quest", (q) =>
q.eq("userId", user._id).eq("questId", questId)
)
.unique();
if (!userQuest) throw new ConvexError("Quest not started");
if (userQuest.completedAt) throw new ConvexError("Quest already completed");
// 2. Verify location exists
const location = await ctx.db.get(locationId);
if (!location) throw new ConvexError("Location not found");
// 3. Verify location belongs to quest
if (location.questId !== questId)
throw new ConvexError("Location does not belong to this quest");
// 4. Verify location not already completed
const existing = await ctx.db
.query("userLocations")
.withIndex("by_user_and_location", (q) =>
q.eq("userId", user._id).eq("locationId", locationId)
)
.unique();
if (existing) throw new ConvexError("Location already completed");
// 5. Verify photo exists in storage
const storageUrl = await ctx.storage.getUrl(photoStorageId);
if (!storageUrl) throw new ConvexError("Photo not found in storage");
Implementation:
return await ctx.db.insert("userLocations", {
userId: user._id,
questId,
locationId,
photoStorageId,
completedAt: Date.now(),
});
Errors:
- “Quest not started” - User hasn’t started the quest yet
- “Quest already completed” - The quest has already been completed
- “Location not found” - The specified location ID doesn’t exist
- “Location does not belong to this quest” - The location and quest IDs don’t match
- “Location already completed” - User has already completed this location
- “Photo not found in storage” - The provided storage ID is invalid or the file was deleted
- “Not authenticated” - User is not logged in
Complete Example:
import { api } from "@/convex/_generated/api";
import { useMutation, useQuery } from "convex/react";
import { useState } from "react";
function LocationCompletion({ questId, locationId }) {
const [uploading, setUploading] = useState(false);
const generateUploadUrl = useMutation(api.locations.generateUploadUrl);
const completeLocation = useMutation(api.locations.complete);
const location = useQuery(api.locations.get, { locationId });
const handlePhotoCapture = async (file: File) => {
try {
setUploading(true);
// Generate upload URL
const uploadUrl = await generateUploadUrl();
// Upload photo
const uploadResponse = await fetch(uploadUrl, {
method: "POST",
body: file,
});
if (!uploadResponse.ok) {
throw new Error("Failed to upload photo");
}
const { storageId } = await uploadResponse.json();
// Complete location
await completeLocation({
questId,
locationId,
photoStorageId: storageId,
});
console.log("Location completed!");
} catch (error) {
console.error("Error completing location:", error);
} finally {
setUploading(false);
}
};
return (
<div>
<h2>{location?.name}</h2>
<p>{location?.description}</p>
<input
type="file"
accept="image/*"
onChange={(e) => handlePhotoCapture(e.target.files[0])}
disabled={uploading}
/>
</div>
);
}
Schema Reference
The locations are stored with this schema:
locations: defineTable({
questId: v.id("quests"),
name: v.string(),
description: v.string(),
coordinates: v.object({
latitude: v.number(),
longitude: v.number(),
}),
order: v.number(),
})
.index("by_quest", ["questId"])
.index("by_quest_order", ["questId", "order"])
User location completions are stored as:
userLocations: defineTable({
userId: v.id("users"),
questId: v.id("quests"),
locationId: v.id("locations"),
photoStorageId: v.id("_storage"),
completedAt: v.number(),
})
.index("by_user_and_quest", ["userId", "questId"])
.index("by_user_and_location", ["userId", "locationId"])