diff --git a/drizzle/0010_fixed_mongoose.sql b/drizzle/0010_fixed_mongoose.sql new file mode 100644 index 00000000..f94732ca --- /dev/null +++ b/drizzle/0010_fixed_mongoose.sql @@ -0,0 +1,14 @@ +CREATE TABLE `placeListToPlace` ( + `placeListId` int NOT NULL, + `placeId` int NOT NULL, + `addedAt` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT `placeListToPlace_placeId_placeListId` PRIMARY KEY(`placeId`,`placeListId`) +); +--> statement-breakpoint +CREATE TABLE `placeList` ( + `id` serial AUTO_INCREMENT NOT NULL, + `userId` varchar(255) NOT NULL, + CONSTRAINT `placeList_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +ALTER TABLE `user` ADD `visitedPlaceListId` int NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0010_snapshot.json b/drizzle/meta/0010_snapshot.json new file mode 100644 index 00000000..0c689e9f --- /dev/null +++ b/drizzle/meta/0010_snapshot.json @@ -0,0 +1,767 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "af8972c5-02af-44c8-9858-e45177dc6734", + "prevId": "732b0209-84d9-4bce-a2b7-12146c3436b4", + "tables": { + "account": { + "name": "account", + "columns": { + "userId": { + "name": "userId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "account_provider_providerAccountId": { + "name": "account_provider_providerAccountId", + "columns": [ + "provider", + "providerAccountId" + ] + } + }, + "uniqueConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "session_sessionToken": { + "name": "session_sessionToken", + "columns": [ + "sessionToken" + ] + } + }, + "uniqueConstraints": {} + }, + "verificationToken": { + "name": "verificationToken", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token": { + "name": "verificationToken_identifier_token", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {} + }, + "feature": { + "name": "feature", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "amountOfPeople": { + "name": "amountOfPeople", + "type": "enum('none','few','some','many','crowded')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "difficulty": { + "name": "difficulty", + "type": "enum('accessible','normal','smallEffort','hard','dangerous')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "groundType": { + "name": "groundType", + "type": "enum('sand','pebbles','rocks','concrete')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hasBus": { + "name": "hasBus", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hasParking": { + "name": "hasParking", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hasToilet": { + "name": "hasToilet", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hasRestaurant": { + "name": "hasRestaurant", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hasDrinkingWater": { + "name": "hasDrinkingWater", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hasShower": { + "name": "hasShower", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hasLifeguard": { + "name": "hasLifeguard", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hasLeisure": { + "name": "hasLeisure", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dimensions": { + "name": "dimensions", + "type": "tinytext", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "difficultyNotes": { + "name": "difficultyNotes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "feature_id": { + "name": "feature_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {} + }, + "feature_translation": { + "name": "feature_translation", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "feature_id": { + "name": "feature_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locale": { + "name": "locale", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "difficultyNotes": { + "name": "difficultyNotes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "feature_translation_id": { + "name": "feature_translation_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {} + }, + "placeListToPlace": { + "name": "placeListToPlace", + "columns": { + "placeListId": { + "name": "placeListId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "placeId": { + "name": "placeId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedAt": { + "name": "addedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "placeListToPlace_placeId_placeListId": { + "name": "placeListToPlace_placeId_placeListId", + "columns": [ + "placeId", + "placeListId" + ] + } + }, + "uniqueConstraints": {} + }, + "placeList": { + "name": "placeList", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "placeList_id": { + "name": "placeList_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {} + }, + "placeCategory": { + "name": "placeCategory", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "icon": { + "name": "icon", + "type": "tinytext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "tinytext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hasVisitMission": { + "name": "hasVisitMission", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "name": { + "name": "name", + "type": "tinytext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "namePlural": { + "name": "namePlural", + "type": "tinytext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nameGender": { + "name": "nameGender", + "type": "enum('masculine','feminine')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "placeCategory_id": { + "name": "placeCategory_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {} + }, + "placeCategory_translation": { + "name": "placeCategory_translation", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "placeCategory_id": { + "name": "placeCategory_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locale": { + "name": "locale", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "tinytext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "namePlural": { + "name": "namePlural", + "type": "tinytext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nameGender": { + "name": "nameGender", + "type": "enum('masculine','feminine')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "placeCategory_translation_id": { + "name": "placeCategory_translation_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {} + }, + "place": { + "name": "place", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "mainImage": { + "name": "mainImage", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "POINT SRID 25831", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mainCategoryId": { + "name": "mainCategoryId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "featuresId": { + "name": "featuresId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "tinytext", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "place_id": { + "name": "place_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {} + }, + "placeToPlaceCategory": { + "name": "placeToPlaceCategory", + "columns": { + "placeId": { + "name": "placeId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "categoryId": { + "name": "categoryId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "placeToPlaceCategory_categoryId_placeId": { + "name": "placeToPlaceCategory_categoryId_placeId", + "columns": [ + "categoryId", + "placeId" + ] + } + }, + "uniqueConstraints": {} + }, + "place_translation": { + "name": "place_translation", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "place_id": { + "name": "place_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locale": { + "name": "locale", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "tinytext", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "place_translation_id": { + "name": "place_translation_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hashedPassword": { + "name": "hashedPassword", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "visitedPlaceListId": { + "name": "visitedPlaceListId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_id": { + "name": "user_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ] + } + } + } + }, + "schemas": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 146214aa..226a48ec 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1699126270640, "tag": "0009_blue_selene", "breakpoints": true + }, + { + "idx": 10, + "version": "5", + "when": 1699292532393, + "tag": "0010_fixed_mongoose", + "breakpoints": true } ] } \ No newline at end of file diff --git a/e2e-tests/visit-missions.test.ts b/e2e-tests/visit-missions.test.ts new file mode 100644 index 00000000..59b853d2 --- /dev/null +++ b/e2e-tests/visit-missions.test.ts @@ -0,0 +1,102 @@ +import { Page, expect, test } from '@playwright/test' + +test('has title', async ({ page }) => { + await page.goto('/en/missions') + + await expect(page).toHaveTitle(/Missions/) +}) + +test.skip('missions + login', async ({ page }) => { + const PLACE_ID = 57 + const PLACE_NAME = 'Can Pella i Forgas' + const CATEGORY_NAME_PLURAL = 'Defense Towers' + + await page.goto('/en/missions') + + await expect( + page + .getByRole('button', { name: CATEGORY_NAME_PLURAL }) + .getByRole('progressbar') + ).toHaveAttribute('aria-valuenow', '0') + + await page.getByRole('button', { name: CATEGORY_NAME_PLURAL }).focus() + await page.getByRole('button', { name: CATEGORY_NAME_PLURAL }).press('Enter') + await expect( + page + .getByRole('button', { name: PLACE_NAME }) + .getByLabel('Not visited', { exact: true }) + ).toBeVisible() + await page.getByRole('button', { name: PLACE_NAME }).click() + + await page.getByRole('button', { name: 'Validate visit' }).click() + + await expect(page.getByText('Login required')).toBeVisible() + + await page.getByRole('link', { name: 'Login' }).click() + await fillLoginForm(page) + await page.getByRole('navigation').getByLabel('Missions').click() + + await expect( + page + .getByRole('button', { name: CATEGORY_NAME_PLURAL }) + .getByRole('progressbar') + ).toHaveAttribute('aria-valuenow', '0') + + await page.getByRole('button', { name: CATEGORY_NAME_PLURAL }).focus() + await page.getByRole('button', { name: CATEGORY_NAME_PLURAL }).press('Enter') + await expect( + page + .getByRole('button', { name: PLACE_NAME }) + .getByLabel('Not visited', { exact: true }) + ).toBeVisible() + await page.getByRole('button', { name: PLACE_NAME }).click() + await expect(page.getByRole('banner').getByText(PLACE_NAME)).toBeVisible() + await expect( + page.getByRole('link', { name: 'View full place info' }) + ).toHaveAttribute('href', `/en/explore/places/${PLACE_ID}`) + + await page.getByRole('button', { name: 'Validate visit' }).click() + await expect( + page.getByRole('banner').getByText('Validate visit') + ).toBeVisible() + await page.getByRole('button', { name: 'Validate visit' }).click() + await expect(page.getByRole('alert').getByText('Error')).toBeVisible() + await page + .getByRole('button', { name: 'Continue without validating' }) + .click() + + await expect( + page.getByRole('banner').getByText('Validate visit') + ).not.toBeVisible() + + await expect( + page + .getByRole('button', { name: CATEGORY_NAME_PLURAL }) + .getByRole('progressbar') + ).not.toHaveAttribute('aria-valuenow', '0') + + await expect( + page.getByRole('button', { name: CATEGORY_NAME_PLURAL }) + ).toHaveAttribute('aria-expanded', 'true') + await expect( + page + .getByRole('button', { name: PLACE_NAME }) + .getByLabel('Visited', { exact: true }) + ).toBeVisible() + await page.getByRole('button', { name: PLACE_NAME }).click() + await page.getByRole('button', { name: 'Validate visit' }).click() + + await expect( + page.getByRole('button', { name: 'Continue without validating' }) + ).not.toBeVisible() +}) + +async function fillLoginForm( + page: Page, + email = 'test@example.com', + password = 'Test123456' +) { + await page.getByLabel('Email', { exact: true }).fill(email) + await page.getByLabel('Password', { exact: true }).fill(password) + await page.getByRole('button', { name: 'Send' }).click() +} diff --git a/src/app/[locale]/(app)/missions/_components/place-prevew-modal.tsx b/src/app/[locale]/(app)/missions/_components/place-prevew-modal.tsx index 297c407e..487a3e3f 100644 --- a/src/app/[locale]/(app)/missions/_components/place-prevew-modal.tsx +++ b/src/app/[locale]/(app)/missions/_components/place-prevew-modal.tsx @@ -17,38 +17,17 @@ import { import { useTranslations } from 'next-intl' import Link from 'next-intl/link' import { FC } from 'react' +import { LinkButton } from '~/components/links/link-button' import { Map } from '~/components/map/map' import { PlaceCategoryTagList } from '~/components/place-category-tags/place-category-tag-list' import { shotConfettiStars } from '~/helpers/confetti' import { makeImageUrl } from '~/helpers/images' -import { - PlaceCategoryColor, - PlaceCategoryIcon as PlaceCategoryIconType, -} from '~/server/db/constants/places' +import { VisitMissionPlace } from '~/server/db/constants/missions' import { ValidatePlaceVisitModal } from './validate-place-visit-modal' export const PlacePreviewModal: FC< Omit & { - place: { - id: number - name: string - description: string | null - mainImage: string | null - images: { key: string }[] - mainCategory: { - icon: PlaceCategoryIconType - name: string - color: PlaceCategoryColor - } - categories: { - icon: PlaceCategoryIconType - name: string - }[] - location: { - lat: number - lng: number - } - } | null + place: VisitMissionPlace | null } > = ({ isOpen, onOpenChange, place }) => { const t = useTranslations('missions') @@ -126,8 +105,7 @@ export const PlacePreviewModal: FC< - + - - - + {!isAlreadyVisited && ( + <> + + + + + )} ) : ( diff --git a/src/app/[locale]/(app)/missions/_components/visit-missions-acordion.tsx b/src/app/[locale]/(app)/missions/_components/visit-missions-acordion.tsx index 33f5fa15..c5894e5d 100644 --- a/src/app/[locale]/(app)/missions/_components/visit-missions-acordion.tsx +++ b/src/app/[locale]/(app)/missions/_components/visit-missions-acordion.tsx @@ -12,11 +12,11 @@ import { IconMap, } from '@tabler/icons-react' import { useTranslations } from 'next-intl' -import Link from 'next-intl/link' import { FC, useState } from 'react' import { PlaceCategoryIcon } from '~/components/icons/place-category-icon' +import { LinkButton } from '~/components/links/link-button' import { cn } from '~/helpers/cn' -import { VisitMission } from '~/server/db/constants/missions' +import { VisitMission, VisitMissionPlace } from '~/server/db/constants/missions' import { PlaceCategoryColor, PlaceCategoryIcon as PlaceCategoryIconType, @@ -28,9 +28,8 @@ export const VisitMissionsAcordion: FC<{ }> = ({ visitMissions }) => { const t = useTranslations('missions') const { isOpen, onOpen, onOpenChange } = useDisclosure() - const [modalPlacePartialInfo, setModalPlacePartialInfo] = useState< - (typeof visitMissions)[number]['places'][number] | null - >(null) + const [modalPlacePartialInfo, setModalPlacePartialInfo] = + useState(null) return ( <> @@ -63,8 +62,7 @@ export const VisitMissionsAcordion: FC<{ } >
- +
    {places.map((place) => ( - + + + ))}
diff --git a/src/app/[locale]/(app)/missions/_hooks/useLocationValidator.ts b/src/app/[locale]/(app)/missions/_hooks/useLocationValidator.ts new file mode 100644 index 00000000..8cc569bd --- /dev/null +++ b/src/app/[locale]/(app)/missions/_hooks/useLocationValidator.ts @@ -0,0 +1,105 @@ +import haversine from 'haversine-distance' +import { useState } from 'react' +import { MapPoint } from '~/helpers/spatial-data' + +/** In meters */ +const MAX_DISTANCE_TO_PLACE = process.env.NODE_ENV === 'development' ? 500 : 25 + +/** In meters */ +const MIN_LOCATION_ACCURACY = 50 + +type ErrorCodes = + | 'too-low-accuracy' + | 'too-far' + | 'geolocation-not-supported' + | 'timeout' + | 'position-unavailable' + | 'permission-denied' + +export function useLocationValidator(expectedLocation: MapPoint) { + const [deviceLocationError, setDeviceLocationError] = + useState(null) + const [loadingDeviceLocation, setLoadingDeviceLocation] = useState(false) + + /** + * Requests access to the device location and validates that it is closes than {@link MAX_DISTANCE_TO_PLACE}. + * + * If the location is not valid, it sets the error code in {@link deviceLocationError}. + * + * It automatically sets {@link loadingDeviceLocation} while the location is being accessed. + * @returns {Promise} {@link MapPoint} if the location is valid, {@link null} otherwise. + */ + const validateLocation = async (): Promise => { + setLoadingDeviceLocation(true) + + const data = await new Promise< + | { + error: null + location: MapPoint + } + | { + error: ErrorCodes + location: MapPoint | null + } + >((resolve) => { + if (!('geolocation' in navigator)) { + return resolve({ + error: 'geolocation-not-supported', + location: null, + }) + } + + navigator.geolocation.getCurrentPosition( + (position) => { + const deviceLocation: MapPoint = { + lat: position.coords.latitude, + lng: position.coords.longitude, + } + const distance = haversine(deviceLocation, expectedLocation) + if (distance > MAX_DISTANCE_TO_PLACE) { + return resolve({ + error: 'too-far', + location: deviceLocation, + }) + } + + const accuracy = position.coords.accuracy + if (accuracy > MIN_LOCATION_ACCURACY) { + return resolve({ + error: 'too-low-accuracy', + location: deviceLocation, + }) + } + + return resolve({ location: deviceLocation, error: null }) + }, + (error) => { + if (error.POSITION_UNAVAILABLE) { + return resolve({ error: 'position-unavailable', location: null }) + } else if (error.TIMEOUT) { + return resolve({ error: 'timeout', location: null }) + } else if (error.PERMISSION_DENIED) { + return resolve({ error: 'permission-denied', location: null }) + } else { + return resolve({ error: 'position-unavailable', location: null }) + } + }, + { + enableHighAccuracy: true, + } + ) + }) + + setLoadingDeviceLocation(false) + + setDeviceLocationError(data.error) + + return data.error ? null : data.location + } + + return { + validateLocation, + deviceLocationError, + loadingDeviceLocation, + } +} diff --git a/src/components/map/custom-layout-controls.tsx b/src/components/map/custom-layout-controls.tsx index 5bcd596a..d1537d92 100644 --- a/src/components/map/custom-layout-controls.tsx +++ b/src/components/map/custom-layout-controls.tsx @@ -42,6 +42,8 @@ export const CustomLayersControl: FC<{ ${selectedLayerFullData.attribution.name}`} url={selectedLayerFullData.tileUrlTemplate} + maxZoom={selectedLayerFullData.maxZoom} + id={selectedLayerFullData.id} /> )} {!hide && ( diff --git a/src/messages/ca.json b/src/messages/ca.json index ba59a422..43061a6a 100644 --- a/src/messages/ca.json +++ b/src/messages/ca.json @@ -99,7 +99,7 @@ "use-device-location-to-validate": "S'utilitzarà la ubicació del dispositiu per validar la visita.", "grant-location-permission-to-continue": "Permet l'accés a la teva ubicació quan se't demani.", "device-location-errors": { - "too-low-accuracy": "La precisió de la ubicació és massa baixa.", + "too-low-accuracy": "La precisió de la ubicació és massa baixa. Torna-ho a intentar en uns segons.", "too-far": "Estàs massa lluny del lloc.", "geolocation-not-supported": "La geolocalització no està suportada pel teu dispositiu.", "permission-denied": "No s'ha permès l'accés a la ubicació. Ves a la configuració del teu dispositiu o navegador i permet l'accés a la ubicació per a aquesta applicació web.", @@ -109,7 +109,8 @@ "login-required": "Cal iniciar sessió", "login-required-description": "Per validar la visita cal estar registrat.", "login": "Iniciar sessió", - "register": "Registrar-se" + "register": "Registrar-se", + "loading": "Carregant..." }, "hub": { "meta": { diff --git a/src/messages/en.json b/src/messages/en.json index acb41009..7a29af69 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -99,7 +99,7 @@ "use-device-location-to-validate": "Use device location to validate.", "grant-location-permission-to-continue": "Grant location permission when prompted.", "device-location-errors": { - "too-low-accuracy": "Location accuracy is too low.", + "too-low-accuracy": "Location accuracy is too low. Try again in a few seconds.", "unknown": "Unknown error", "too-far": "You are too far from the place.", "geolocation-not-supported": "Geolocation is not supported by this device.", @@ -111,7 +111,8 @@ "login-required": "Login required", "login-required-description": "You need to be logged in to validate a visit.", "login": "Login", - "register": "Register" + "register": "Register", + "loading": "Loading..." }, "hub": { "meta": { diff --git a/src/schemas/placeLists.ts b/src/schemas/placeLists.ts new file mode 100644 index 00000000..3a981877 --- /dev/null +++ b/src/schemas/placeLists.ts @@ -0,0 +1,9 @@ +import { z } from 'zod' + +export const addToVisitedPlacesListSchema = z.object({ + placeId: z.number().int().min(0).optional(), +}) + +export type AddToVisitedPlacesListSchemaType = z.infer< + typeof addToVisitedPlacesListSchema +> diff --git a/src/server/api/router/auth.ts b/src/server/api/router/auth.ts index 793d596c..fa645bd3 100644 --- a/src/server/api/router/auth.ts +++ b/src/server/api/router/auth.ts @@ -1,27 +1,22 @@ import 'server-only' -import bcrypt from 'bcryptjs' -import { eq } from 'drizzle-orm' -import { v4 as uuidv4 } from 'uuid' +import { TRPCError } from '@trpc/server' import { registerSchema } from '~/schemas/auth' -import { db } from '~/server/db/db' -import { users } from '~/server/db/schema/users' +import { initializeUserInDatabase } from '~/server/helpers/auth/initialize-user' import { procedure, router } from '~/server/trpc' export const authRouter = router({ register: procedure.input(registerSchema).mutation(async ({ input }) => { - const existingUser = await db.query.users.findFirst({ - where: eq(users.email, input.email), - }) - - if (existingUser) { - throw new Error('User already exists') + try { + return await initializeUserInDatabase({ + email: input.email, + password: input.password, + }) + } catch (e) { + return new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: (e as Error).message, + }) } - - return await db.insert(users).values({ - id: uuidv4(), - email: input.email, - hashedPassword: bcrypt.hashSync(input.password, 10), - }) }), }) diff --git a/src/server/api/router/index.ts b/src/server/api/router/index.ts index bfb351a3..cf2ff367 100644 --- a/src/server/api/router/index.ts +++ b/src/server/api/router/index.ts @@ -3,6 +3,7 @@ import 'server-only' import { router } from '~/server/trpc' import { authRouter } from './auth' import { missionsRouter } from './missions' +import { placeListsRouter } from './placeLists' import { placesRouter } from './places' import { profileRouter } from './profile' @@ -11,6 +12,7 @@ export const apiRouter = router({ auth: authRouter, profile: profileRouter, missions: missionsRouter, + placeLists: placeListsRouter, }) export type ApiRouter = typeof apiRouter diff --git a/src/server/api/router/missions.ts b/src/server/api/router/missions.ts index a9ae5ce6..87740cd1 100644 --- a/src/server/api/router/missions.ts +++ b/src/server/api/router/missions.ts @@ -1,10 +1,12 @@ -import { sql } from 'drizzle-orm' import 'server-only' -import { calculateLocation } from '~/helpers/spatial-data' + +import { sql } from 'drizzle-orm' +import { getPoint } from '~/helpers/spatial-data' import { getVisitMissionsSchema } from '~/schemas/missions' import { db } from '~/server/db/db' import { places, placesToPlaceCategories } from '~/server/db/schema' +import { getVisitedPlacesIdsByUserId } from '~/server/helpers/db-queries/placeLists' import { selectPoint } from '~/server/helpers/spatial-data' import { flattenTranslationsOnExecute, @@ -130,42 +132,38 @@ const getVisitMissions = flattenTranslationsOnExecute( export const missionsRouter = router({ getVisitMissions: procedure .input(getVisitMissionsSchema) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { const result = await getVisitMissions.execute({ locale: input.locale, placeId: input.placeId, }) + + const visitedPlacesIds = await getVisitedPlacesIdsByUserId( + ctx.session?.user.id + ) + return result - .map(({ places, mainPlaces, ...category }) => ({ - category, - places: [ - ...mainPlaces.map((place) => mapPlace(place)), - ...places.map(({ place }) => mapPlace(place)), - ], - })) + .map(({ places, mainPlaces, ...category }) => { + const mainPlacesIds = mainPlaces.map((place) => place.id) + return { + category, + places: [ + ...mainPlaces, + ...places + .map(({ place }) => place) + .filter((place) => !mainPlacesIds.includes(place.id)), + ].map(({ location, categories, ...place }) => ({ + ...place, + location: getPoint(location), + categories: categories.map(({ category }) => category), + images: [], + missionStatus: { + visited: visitedPlacesIds.has(place.id), + verified: false, + }, + })), + } + }) .filter(({ places }) => places.length > 0) }), }) - -const mapPlace = < - T extends { - location: any // eslint-disable-line @typescript-eslint/no-explicit-any - categories: { - category: any // eslint-disable-line @typescript-eslint/no-explicit-any - }[] - }, ->( - place: T -) => { - const { categories, ...placeWithLocation } = calculateLocation(place) - return { - missionStatus: { - visited: false, - verified: false, - }, - categories: categories.map(({ category }) => category), - images: [], - - ...placeWithLocation, - } -} diff --git a/src/server/api/router/placeLists.ts b/src/server/api/router/placeLists.ts new file mode 100644 index 00000000..7b3d6bb2 --- /dev/null +++ b/src/server/api/router/placeLists.ts @@ -0,0 +1,31 @@ +import 'server-only' + +import { sql } from 'drizzle-orm' +import { addToVisitedPlacesListSchema } from '~/schemas/placeLists' +import { db } from '~/server/db/db' +import { placeListToPlace } from '~/server/db/schema' +import { getVisitedPlaceListIdByUserId } from '~/server/helpers/db-queries/placeLists' +import { protectedProcedure, router } from '~/server/trpc' + +const addToPlaceList = db + .insert(placeListToPlace) + .values({ + placeListId: sql.placeholder('placeListId'), + placeId: sql.placeholder('placeId'), + }) + .prepare() + +export const placeListsRouter = router({ + addToVisitedPlacesList: protectedProcedure + .input(addToVisitedPlacesListSchema) + .mutation(async ({ input, ctx }) => { + const visitedPlaceListId = await getVisitedPlaceListIdByUserId( + ctx.session.user.id + ) + + return await addToPlaceList.execute({ + placeListId: visitedPlaceListId, + placeId: input.placeId, + }) + }), +}) diff --git a/src/server/auth.ts b/src/server/auth.ts index 228642c5..39fa6cb0 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -11,10 +11,16 @@ import { loginSchema } from '~/schemas/auth' import { updateSessionSchema } from '~/schemas/profile' import { db } from './db/db' import { users } from './db/schema' +import { initializeUserInDatabase } from './helpers/auth/initialize-user' export const authOptions: AuthOptions = { - // Note: Cast required to workaround issue https://github.com/nextauthjs/next-auth/issues/8283 - adapter: DrizzleAdapter(db) as AuthOptions['adapter'], + adapter: { + ...DrizzleAdapter(db), + + async createUser(data) { + return await initializeUserInDatabase(data) + }, + }, providers: [ GoogleProvider({ clientId: env.GOOGLE_CLIENT_ID, diff --git a/src/server/db/constants/missions.ts b/src/server/db/constants/missions.ts index a823e255..a6307c6c 100644 --- a/src/server/db/constants/missions.ts +++ b/src/server/db/constants/missions.ts @@ -3,6 +3,33 @@ import { PlaceCategoryIcon as PlaceCategoryIconType, } from '~/server/db/constants/places' +export type VisitMissionPlace = { + id: number + name: string + mainImage: string | null + images: { + key: string + }[] + description: string | null + location: { + lat: number + lng: number + } + mainCategory: { + icon: PlaceCategoryIconType + name: string + color: PlaceCategoryColor + } + categories: { + icon: PlaceCategoryIconType + name: string + }[] + missionStatus: { + visited: boolean + verified: boolean + } +} + export type VisitMission = { category: { id: number @@ -10,28 +37,5 @@ export type VisitMission = { icon: PlaceCategoryIconType | null color: PlaceCategoryColor } - places: { - id: number - name: string - mainImage: string | null - images: { key: string }[] - description: string | null - location: { - lat: number - lng: number - } - mainCategory: { - icon: PlaceCategoryIconType - name: string - color: PlaceCategoryColor - } - categories: { - icon: PlaceCategoryIconType - name: string - }[] - missionStatus: { - visited?: boolean - verified?: boolean - } - }[] + places: VisitMissionPlace[] } diff --git a/src/server/db/schema/auth.ts b/src/server/db/schema/auth.ts new file mode 100644 index 00000000..dd268326 --- /dev/null +++ b/src/server/db/schema/auth.ts @@ -0,0 +1,65 @@ +import type { AdapterAccount } from '@auth/core/adapters' +import { relations } from 'drizzle-orm' +import { + int, + mysqlTable, + primaryKey, + text, + timestamp, + varchar, +} from 'drizzle-orm/mysql-core' +import { users } from './users' + +export const accounts = mysqlTable( + 'account', + { + userId: varchar('userId', { length: 255 }).notNull(), + type: varchar('type', { length: 255 }) + .$type() + .notNull(), + provider: varchar('provider', { length: 255 }).notNull(), + providerAccountId: varchar('providerAccountId', { length: 255 }).notNull(), + refresh_token: text('refresh_token'), + access_token: text('access_token'), + expires_at: int('expires_at'), + token_type: varchar('token_type', { length: 255 }), + scope: varchar('scope', { length: 255 }), + id_token: text('id_token'), + session_state: varchar('session_state', { length: 255 }), + }, + (account) => ({ + compoundKey: primaryKey(account.provider, account.providerAccountId), + }) +) + +export const accountsRelations = relations(accounts, ({ one }) => ({ + user: one(users, { + fields: [accounts.userId], + references: [users.id], + }), +})) + +export const sessions = mysqlTable('session', { + sessionToken: varchar('sessionToken', { length: 255 }).notNull().primaryKey(), + userId: varchar('userId', { length: 255 }).notNull(), + expires: timestamp('expires', { mode: 'date' }).notNull(), +}) + +export const sessionsRelations = relations(sessions, ({ one }) => ({ + user: one(users, { + fields: [sessions.userId], + references: [users.id], + }), +})) + +export const verificationTokens = mysqlTable( + 'verificationToken', + { + identifier: varchar('identifier', { length: 255 }).notNull(), + token: varchar('token', { length: 255 }).notNull(), + expires: timestamp('expires', { mode: 'date' }).notNull(), + }, + (vt) => ({ + compoundKey: primaryKey(vt.identifier, vt.token), + }) +) diff --git a/src/server/db/schema/index.ts b/src/server/db/schema/index.ts index 9254d222..4f56433a 100644 --- a/src/server/db/schema/index.ts +++ b/src/server/db/schema/index.ts @@ -1,3 +1,5 @@ +export * from './auth' export * from './features' +export * from './placeLists' export * from './places' export * from './users' diff --git a/src/server/db/schema/placeLists.ts b/src/server/db/schema/placeLists.ts new file mode 100644 index 00000000..f81649a7 --- /dev/null +++ b/src/server/db/schema/placeLists.ts @@ -0,0 +1,39 @@ +import { relations, sql } from 'drizzle-orm' +import { + int, + mysqlTable, + primaryKey, + serial, + timestamp, +} from 'drizzle-orm/mysql-core' +import { userIdColumnType } from '../utilities' +import { users } from './users' + +export const placeLists = mysqlTable('placeList', { + id: serial('id').primaryKey(), + userId: userIdColumnType('userId').notNull(), +}) + +export const placeListToPlace = mysqlTable( + 'placeListToPlace', + { + placeListId: int('placeListId').notNull(), + placeId: int('placeId').notNull(), + addedAt: timestamp('addedAt') + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + (table) => { + return { + pk: primaryKey(table.placeId, table.placeListId), + } + } +) + +export const placeListRelations = relations(placeLists, (r) => ({ + places: r.many(placeListToPlace), + owner: r.one(users, { + fields: [placeLists.userId], + references: [users.id], + }), +})) diff --git a/src/server/db/schema/places.ts b/src/server/db/schema/places.ts index 1b49c252..e910ead1 100644 --- a/src/server/db/schema/places.ts +++ b/src/server/db/schema/places.ts @@ -7,7 +7,7 @@ import { text, tinytext, } from 'drizzle-orm/mysql-core' -import { features } from '.' +import { features, placeListToPlace } from '.' import { pointType } from '../../helpers/spatial-data' import { mysqlTableWithTranslations } from '../../helpers/translations/db-tables' import { @@ -48,6 +48,7 @@ export const placesRelations = relations(places, (r) => ({ fields: [places.featuresId], references: [features.id], }), + placeLists: r.many(placeListToPlace), })) export const { diff --git a/src/server/db/schema/users.ts b/src/server/db/schema/users.ts index 6eb678f7..a7e7e295 100644 --- a/src/server/db/schema/users.ts +++ b/src/server/db/schema/users.ts @@ -1,16 +1,10 @@ -import type { AdapterAccount } from '@auth/core/adapters' import { relations } from 'drizzle-orm' -import { - int, - mysqlTable, - primaryKey, - text, - timestamp, - varchar, -} from 'drizzle-orm/mysql-core' +import { int, mysqlTable, timestamp, varchar } from 'drizzle-orm/mysql-core' +import { userIdColumnType } from '../utilities' +import { placeLists } from './placeLists' export const users = mysqlTable('user', { - id: varchar('id', { length: 255 }).notNull().primaryKey(), + id: userIdColumnType('id').notNull().primaryKey(), name: varchar('name', { length: 255 }), hashedPassword: varchar('hashedPassword', { length: 255 }), email: varchar('email', { length: 255 }).unique().notNull(), @@ -19,58 +13,13 @@ export const users = mysqlTable('user', { fsp: 3, }), image: varchar('image', { length: 255 }), -}) - -export const accounts = mysqlTable( - 'account', - { - userId: varchar('userId', { length: 255 }).notNull(), - type: varchar('type', { length: 255 }) - .$type() - .notNull(), - provider: varchar('provider', { length: 255 }).notNull(), - providerAccountId: varchar('providerAccountId', { length: 255 }).notNull(), - refresh_token: text('refresh_token'), - access_token: text('access_token'), - expires_at: int('expires_at'), - token_type: varchar('token_type', { length: 255 }), - scope: varchar('scope', { length: 255 }), - id_token: text('id_token'), - session_state: varchar('session_state', { length: 255 }), - }, - (account) => ({ - compoundKey: primaryKey(account.provider, account.providerAccountId), - }) -) - -export const accountsRelations = relations(accounts, ({ one }) => ({ - user: one(users, { - fields: [accounts.userId], - references: [users.id], - }), -})) -export const sessions = mysqlTable('session', { - sessionToken: varchar('sessionToken', { length: 255 }).notNull().primaryKey(), - userId: varchar('userId', { length: 255 }).notNull(), - expires: timestamp('expires', { mode: 'date' }).notNull(), + visitedPlaceListId: int('visitedPlaceListId').notNull(), }) -export const sessionsRelations = relations(sessions, ({ one }) => ({ - user: one(users, { - fields: [sessions.userId], - references: [users.id], +export const usersRelations = relations(users, ({ one }) => ({ + visitedPlaceList: one(placeLists, { + fields: [users.visitedPlaceListId], + references: [placeLists.id], }), })) - -export const verificationTokens = mysqlTable( - 'verificationToken', - { - identifier: varchar('identifier', { length: 255 }).notNull(), - token: varchar('token', { length: 255 }).notNull(), - expires: timestamp('expires', { mode: 'date' }).notNull(), - }, - (vt) => ({ - compoundKey: primaryKey(vt.identifier, vt.token), - }) -) diff --git a/src/server/db/utilities.ts b/src/server/db/utilities.ts index 05b078e2..5e5fce6e 100644 --- a/src/server/db/utilities.ts +++ b/src/server/db/utilities.ts @@ -12,3 +12,6 @@ export const locale = (name: T) => export const gender = (name: T) => mysqlEnum(name, genderValues) + +export const userIdColumnType = (name: T) => + varchar(name, { length: 255 }) diff --git a/src/server/get-server-thing.ts b/src/server/get-server-thing.ts index 8df27f08..3e8be53e 100644 --- a/src/server/get-server-thing.ts +++ b/src/server/get-server-thing.ts @@ -1,4 +1,4 @@ -import { authOptions } from './auth' +import 'server-only' import { GetServerSidePropsContext, @@ -7,9 +7,10 @@ import { } from 'next' import { getServerSession } from 'next-auth' import { apiRouter } from './api/router' +import { authOptions } from './auth' export async function getTrpc() { - const session = await getServerSession() + const session = await getSession() const trpcServerSide = apiRouter.createCaller({ session, }) diff --git a/src/server/helpers/auth/initialize-user.ts b/src/server/helpers/auth/initialize-user.ts new file mode 100644 index 00000000..54abbe28 --- /dev/null +++ b/src/server/helpers/auth/initialize-user.ts @@ -0,0 +1,64 @@ +import bcrypt from 'bcryptjs' +import { eq } from 'drizzle-orm' +import { v4 as uuidv4 } from 'uuid' +import { db } from '~/server/db/db' +import { placeLists } from '~/server/db/schema' +import { users } from '~/server/db/schema/users' + +export async function initializeUserInDatabase(newUser: { + email: string + password?: string + emailVerified?: Date | null + name?: string | null + image?: string | null +}) { + return await db.transaction(async (tx) => { + if (newUser.password) { + const existingUser = await tx.query.users.findFirst({ + where: eq(users.email, newUser.email), + }) + if (existingUser) { + throw new Error('User already exists with that email') + } + } + + const userId = uuidv4() + + const visitedPlaceListId = Number( + ( + await tx.insert(placeLists).values({ + userId, + }) + ).insertId + ) + + if ( + !( + await tx.query.placeLists.findFirst({ + columns: { id: true }, + where: eq(placeLists.id, visitedPlaceListId), + }) + )?.id + ) { + throw new Error('Error creating visitedPlaceList') + } + + await tx.insert(users).values({ + id: userId, + email: newUser.email, + emailVerified: newUser.emailVerified, + name: newUser.name, + image: newUser.image, + hashedPassword: newUser.password + ? bcrypt.hashSync(newUser.password, 10) + : null, + visitedPlaceListId: visitedPlaceListId, + }) + + return await tx + .select() + .from(users) + .where(eq(users.id, userId)) + .then((res) => res[0]) + }) +} diff --git a/src/server/helpers/db-queries/placeLists.ts b/src/server/helpers/db-queries/placeLists.ts new file mode 100644 index 00000000..52fa82f8 --- /dev/null +++ b/src/server/helpers/db-queries/placeLists.ts @@ -0,0 +1,43 @@ +import 'server-only' + +import { sql } from 'drizzle-orm' +import { db } from '~/server/db/db' + +export async function getVisitedPlaceListIdByUserId(userId: string) { + const result = await getVisitedPlaceListId.execute({ + userId, + }) + if (!result) throw new Error('User has no visited place list') + + return result.visitedPlaceListId +} + +const getVisitedPlaceListId = db.query.users + .findFirst({ + columns: { + visitedPlaceListId: true, + }, + where: (users, { eq }) => eq(users.id, sql.placeholder('userId')), + }) + .prepare() + +const getPlacesInList = db.query.placeListToPlace + .findMany({ + columns: { + placeId: true, + }, + where: (placeListToPlace, { eq }) => + eq(placeListToPlace.placeListId, sql.placeholder('placeListId')), + }) + .prepare() + +export async function getVisitedPlacesIdsByUserId(userId?: string) { + if (!userId) return new Set() + + const visitedPlaceListId = await getVisitedPlaceListIdByUserId(userId) + + const result = await getPlacesInList.execute({ + placeListId: visitedPlaceListId, + }) + return new Set(result.map((place) => place.placeId)) +}