Skip to main content

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 });
questId
Id<'quests'>
required
The unique identifier of the quest
locations
Location[]
Array of locations ordered by the order field
Location Object Structure:
_id
Id<'locations'>
Unique location identifier
questId
Id<'quests'>
The quest this location belongs to
name
string
Location name
description
string
Detailed location description
coordinates
{ latitude: number, longitude: number }
GPS coordinates of the location
order
number
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 });
locationId
Id<'locations'>
required
The unique identifier of the location
location
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 });
questId
Id<'quests'>
required
The quest ID to get completed locations for
userLocations
UserLocation[]
Array of user location completion records
UserLocation Object:
_id
Id<'userLocations'>
Unique user location record identifier
userId
Id<'users'>
The user’s ID
questId
Id<'quests'>
The quest ID
locationId
Id<'locations'>
The location ID
photoStorageId
Id<'_storage'>
Reference to the uploaded photo in Convex storage
completedAt
number
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();
uploadUrl
string
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 });
questId
Id<'quests'>
required
The ID of the quest this location belongs to
locationId
Id<'locations'>
required
The ID of the location to complete
photoStorageId
Id<'_storage'>
required
The storage ID of the uploaded photo (obtained from the upload response)
userLocationId
Id<'userLocations'>
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"])

Build docs developers (and LLMs) love