Compare commits
2 Commits
feature/ba
...
feature/an
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75db2ea8f5 | ||
|
|
e633d693da |
42
.env.example
42
.env.example
@@ -1,39 +1,21 @@
|
|||||||
# ─────────────────────────────────────────────
|
|
||||||
# Pantree Backend — Environment Variables
|
|
||||||
# Copy to .env and fill in real values.
|
|
||||||
# NEVER commit .env to version control.
|
|
||||||
# ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
# Server
|
# Server
|
||||||
PORT=3000
|
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
# PostgreSQL
|
# Database
|
||||||
DB_HOST=localhost
|
DATABASE_URL=postgresql://user:password@localhost:5432/pantree
|
||||||
DB_PORT=5432
|
|
||||||
DB_NAME=pantree
|
|
||||||
DB_USER=postgres
|
|
||||||
DB_PASSWORD=changeme
|
|
||||||
|
|
||||||
# JWT — use a long random secret in production
|
# JWT
|
||||||
JWT_SECRET=change-this-to-a-long-random-secret
|
JWT_SECRET=change_this_to_a_long_random_secret_at_least_64_chars
|
||||||
JWT_EXPIRES_IN=24h
|
JWT_EXPIRES_IN=24h
|
||||||
|
|
||||||
# Google OAuth
|
# Google OAuth
|
||||||
GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
|
GOOGLE_CLIENT_ID=your_google_client_id_here
|
||||||
|
|
||||||
# Email (SMTP / SendGrid / SES)
|
# SendGrid
|
||||||
EMAIL_HOST=smtp.ethereal.email
|
SENDGRID_API_KEY=your_sendgrid_api_key_here
|
||||||
EMAIL_PORT=587
|
SENDGRID_FROM_EMAIL=noreply@pantree.app
|
||||||
EMAIL_USER=
|
|
||||||
EMAIL_PASS=
|
|
||||||
EMAIL_FROM=noreply@pantree.app
|
|
||||||
|
|
||||||
# App base URL (used in password-reset links)
|
# App
|
||||||
APP_BASE_URL=https://pantree.app
|
FRONTEND_URL=https://pantree.app
|
||||||
|
PASSWORD_RESET_URL=https://pantree.app/reset-password
|
||||||
# Account deletion window (days)
|
|
||||||
ACCOUNT_DELETION_DAYS=15
|
|
||||||
|
|
||||||
# Password reset token expiry (hours)
|
|
||||||
RESET_TOKEN_EXPIRY_HOURS=1
|
|
||||||
|
|||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
76
android/README.md
Normal file
76
android/README.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Pantree Android
|
||||||
|
|
||||||
|
Android client for Pantree — the app that tells you what you can cook with what you already have.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **MVVM** — ViewModel + StateFlow, no LiveData
|
||||||
|
- **Hilt** — dependency injection
|
||||||
|
- **Room** — local cache (pantry items, recipes, shopping lists)
|
||||||
|
- **Retrofit + OkHttp** — network layer with JWT interceptor
|
||||||
|
- **Jetpack Compose** — declarative UI, Material 3
|
||||||
|
- **EncryptedSharedPreferences** — JWT stored at rest
|
||||||
|
- **DataStore** — last-sync timestamp
|
||||||
|
|
||||||
|
## Module Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
app/src/main/java/com/pantree/app/
|
||||||
|
├── data/
|
||||||
|
│ ├── local/ # Room DB, DAOs, entities, TokenStore, SyncPreferences
|
||||||
|
│ ├── model/ # API DTOs (request/response)
|
||||||
|
│ ├── remote/ # ApiService (Retrofit), AuthInterceptor
|
||||||
|
│ └── repository/ # AuthRepository, PantryRepository, RecipeRepository,
|
||||||
|
│ # ShoppingListRepository, SyncRepository
|
||||||
|
├── di/ # Hilt AppModule
|
||||||
|
├── sync/ # SyncManager (lifecycle observer)
|
||||||
|
├── ui/
|
||||||
|
│ ├── components/ # LoadingState, ErrorState, EmptyState, OfflineBanner, PantreeTopBar
|
||||||
|
│ ├── navigation/ # Screen sealed class, PantreeNavGraph
|
||||||
|
│ ├── screens/
|
||||||
|
│ │ ├── auth/ # SplashScreen, SignInScreen, SignUpScreen,
|
||||||
|
│ │ │ # ForgotPasswordScreen, ResetPasswordScreen + AuthViewModel
|
||||||
|
│ │ ├── pantry/ # PantryScreen + PantryViewModel
|
||||||
|
│ │ ├── recipe/ # RecipesScreen, RecipeDetailScreen + RecipeViewModel
|
||||||
|
│ │ ├── shopping/ # ShoppingListsScreen, ShoppingListDetailScreen + ShoppingListViewModel
|
||||||
|
│ │ └── settings/ # SettingsScreen + SettingsViewModel
|
||||||
|
│ └── theme/ # Color, Type, Theme
|
||||||
|
└── util/ # Result<T>, safeApiCall, toUserMessage
|
||||||
|
```
|
||||||
|
|
||||||
|
## UI States
|
||||||
|
|
||||||
|
Every screen handles all four states:
|
||||||
|
|
||||||
|
| State | Implementation |
|
||||||
|
|-------|---------------|
|
||||||
|
| **Loading** | `LoadingState` composable — spinner + contextual message |
|
||||||
|
| **Error** | `ErrorState` composable — emoji + message + retry button |
|
||||||
|
| **Empty** | `EmptyState` composable — emoji + title + subtitle + optional CTA |
|
||||||
|
| **Success** | Full content with `LazyColumn` / detail view |
|
||||||
|
|
||||||
|
Offline: cached data shown read-only, `OfflineBanner` displayed.
|
||||||
|
|
||||||
|
## Sync Strategy
|
||||||
|
|
||||||
|
- `SyncManager` implements `DefaultLifecycleObserver` — triggers on `onStart` (app open + foreground)
|
||||||
|
- Delta sync via `GET /sync?since=<last_timestamp>`
|
||||||
|
- Server timestamp stored in DataStore, used as `since` on next sync
|
||||||
|
- Room cache updated; UI observes `Flow<List<Entity>>` — updates automatically
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Set `BASE_URL` in `app/build.gradle` debug/release buildConfigFields
|
||||||
|
2. Set `GOOGLE_WEB_CLIENT_ID` for Google Sign-In
|
||||||
|
3. Run backend (`npm run dev` from repo root)
|
||||||
|
4. `./gradlew assembleDebug`
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Unit tests
|
||||||
|
./gradlew test
|
||||||
|
|
||||||
|
# Instrumented tests (requires emulator/device)
|
||||||
|
./gradlew connectedAndroidTest
|
||||||
|
```
|
||||||
128
android/app/build.gradle
Normal file
128
android/app/build.gradle
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.application'
|
||||||
|
id 'org.jetbrains.kotlin.android'
|
||||||
|
id 'com.google.dagger.hilt.android'
|
||||||
|
id 'com.google.devtools.ksp'
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace 'com.pantree.app'
|
||||||
|
compileSdk 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "com.pantree.app"
|
||||||
|
minSdk 26
|
||||||
|
targetSdk 34
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0.0"
|
||||||
|
|
||||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
vectorDrawables {
|
||||||
|
useSupportLibrary true
|
||||||
|
}
|
||||||
|
|
||||||
|
buildConfigField "String", "BASE_URL", '"https://api.pantree.app/v1/"'
|
||||||
|
buildConfigField "String", "GOOGLE_WEB_CLIENT_ID", '"YOUR_GOOGLE_WEB_CLIENT_ID"'
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled true
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
debug {
|
||||||
|
buildConfigField "String", "BASE_URL", '"http://10.0.2.2:3000/v1/"'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = '17'
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose true
|
||||||
|
buildConfig true
|
||||||
|
}
|
||||||
|
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion '1.5.11'
|
||||||
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += '/META-INF/{AL2.0,LGPL2.1}'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// Core
|
||||||
|
implementation 'androidx.core:core-ktx:1.13.0'
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
|
||||||
|
implementation 'androidx.activity:activity-compose:1.9.0'
|
||||||
|
|
||||||
|
// Compose BOM
|
||||||
|
implementation platform('androidx.compose:compose-bom:2024.04.01')
|
||||||
|
implementation 'androidx.compose.ui:ui'
|
||||||
|
implementation 'androidx.compose.ui:ui-graphics'
|
||||||
|
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||||
|
implementation 'androidx.compose.material3:material3'
|
||||||
|
implementation 'androidx.compose.material:material-icons-extended'
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
implementation 'androidx.navigation:navigation-compose:2.7.7'
|
||||||
|
|
||||||
|
// Hilt DI
|
||||||
|
implementation 'com.google.dagger:hilt-android:2.51.1'
|
||||||
|
ksp 'com.google.dagger:hilt-android-compiler:2.51.1'
|
||||||
|
implementation 'androidx.hilt:hilt-navigation-compose:1.2.0'
|
||||||
|
|
||||||
|
// Room
|
||||||
|
implementation 'androidx.room:room-runtime:2.6.1'
|
||||||
|
implementation 'androidx.room:room-ktx:2.6.1'
|
||||||
|
ksp 'androidx.room:room-compiler:2.6.1'
|
||||||
|
|
||||||
|
// Retrofit + OkHttp
|
||||||
|
implementation 'com.squareup.retrofit2:retrofit:2.11.0'
|
||||||
|
implementation 'com.squareup.retrofit2:converter-gson:2.11.0'
|
||||||
|
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
||||||
|
implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0'
|
||||||
|
|
||||||
|
// Gson
|
||||||
|
implementation 'com.google.code.gson:gson:2.10.1'
|
||||||
|
|
||||||
|
// ViewModel + StateFlow
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0'
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.7.0'
|
||||||
|
|
||||||
|
// EncryptedSharedPreferences
|
||||||
|
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
|
||||||
|
|
||||||
|
// Google Identity (One Tap)
|
||||||
|
implementation 'com.google.android.gms:play-services-auth:21.1.1'
|
||||||
|
|
||||||
|
// Coil (image loading)
|
||||||
|
implementation 'io.coil-kt:coil-compose:2.6.0'
|
||||||
|
|
||||||
|
// DataStore
|
||||||
|
implementation 'androidx.datastore:datastore-preferences:1.1.0'
|
||||||
|
|
||||||
|
// Coroutines
|
||||||
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0'
|
||||||
|
|
||||||
|
// Testing
|
||||||
|
testImplementation 'junit:junit:4.13.2'
|
||||||
|
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0'
|
||||||
|
testImplementation 'io.mockk:mockk:1.13.10'
|
||||||
|
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||||
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||||
|
androidTestImplementation platform('androidx.compose:compose-bom:2024.04.01')
|
||||||
|
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
|
||||||
|
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||||
|
debugImplementation 'androidx.compose.ui:ui-test-manifest'
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.pantree.app.ui.screens.pantry
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.*
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import com.pantree.app.data.local.entity.PantryItemEntity
|
||||||
|
import com.pantree.app.ui.theme.PantreeTheme
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class PantryScreenTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun emptyState_showsEmptyMessage() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
PantreeTheme {
|
||||||
|
EmptyState(
|
||||||
|
emoji = "\uD83E\uDED9",
|
||||||
|
title = "Your pantry is empty",
|
||||||
|
subtitle = "Add ingredients you have on hand."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeTestRule.onNodeWithText("Your pantry is empty").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pantryItemCard_displaysNameAndQuantity() {
|
||||||
|
val item = PantryItemEntity(
|
||||||
|
id = "1", itemName = "Flour", quantity = 3,
|
||||||
|
lastModified = "2024-01-01T00:00:00Z", createdAt = "2024-01-01T00:00:00Z"
|
||||||
|
)
|
||||||
|
composeTestRule.setContent {
|
||||||
|
PantreeTheme {
|
||||||
|
PantryItemCard(item = item, onEdit = {}, onDelete = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeTestRule.onNodeWithText("Flour").assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithText("Qty: 3").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
39
android/app/src/main/AndroidManifest.xml
Normal file
39
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".PantreeApplication"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.Pantree"
|
||||||
|
android:usesCleartextTraffic="false">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@style/Theme.Pantree">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Deep link for password reset -->
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data
|
||||||
|
android:scheme="https"
|
||||||
|
android:host="pantree.app"
|
||||||
|
android:pathPrefix="/reset-password" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
27
android/app/src/main/java/com/pantree/app/MainActivity.kt
Normal file
27
android/app/src/main/java/com/pantree/app/MainActivity.kt
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package com.pantree.app
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.pantree.app.ui.navigation.PantreeNavGraph
|
||||||
|
import com.pantree.app.ui.theme.PantreeTheme
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
PantreeTheme {
|
||||||
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
|
PantreeNavGraph()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.pantree.app
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
|
||||||
|
@HiltAndroidApp
|
||||||
|
class PantreeApplication : Application()
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.pantree.app.data.local
|
||||||
|
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import com.pantree.app.data.local.dao.PantryDao
|
||||||
|
import com.pantree.app.data.local.dao.RecipeDao
|
||||||
|
import com.pantree.app.data.local.dao.ShoppingListDao
|
||||||
|
import com.pantree.app.data.local.entity.PantryItemEntity
|
||||||
|
import com.pantree.app.data.local.entity.RecipeEntity
|
||||||
|
import com.pantree.app.data.local.entity.RecipeIngredientEntity
|
||||||
|
import com.pantree.app.data.local.entity.ShoppingListEntity
|
||||||
|
import com.pantree.app.data.local.entity.ShoppingListItemEntity
|
||||||
|
|
||||||
|
@Database(
|
||||||
|
entities = [
|
||||||
|
PantryItemEntity::class,
|
||||||
|
RecipeEntity::class,
|
||||||
|
RecipeIngredientEntity::class,
|
||||||
|
ShoppingListEntity::class,
|
||||||
|
ShoppingListItemEntity::class
|
||||||
|
],
|
||||||
|
version = 1,
|
||||||
|
exportSchema = false
|
||||||
|
)
|
||||||
|
abstract class PantreeDatabase : RoomDatabase() {
|
||||||
|
abstract fun pantryDao(): PantryDao
|
||||||
|
abstract fun recipeDao(): RecipeDao
|
||||||
|
abstract fun shoppingListDao(): ShoppingListDao
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.pantree.app.data.local
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "sync_prefs")
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class SyncPreferences @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context
|
||||||
|
) {
|
||||||
|
private val LAST_SYNC_KEY = stringPreferencesKey("last_sync_timestamp")
|
||||||
|
|
||||||
|
val lastSyncTimestamp: Flow<String> = context.dataStore.data.map { prefs ->
|
||||||
|
prefs[LAST_SYNC_KEY] ?: "1970-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateLastSync(timestamp: String) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs[LAST_SYNC_KEY] = timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.pantree.app.data.local
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class TokenStore @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context
|
||||||
|
) {
|
||||||
|
private val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val prefs = EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
"pantree_secure_prefs",
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||||
|
)
|
||||||
|
|
||||||
|
fun saveToken(token: String, expiresAt: String) {
|
||||||
|
prefs.edit()
|
||||||
|
.putString(KEY_TOKEN, token)
|
||||||
|
.putString(KEY_EXPIRES_AT, expiresAt)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getToken(): String? = prefs.getString(KEY_TOKEN, null)
|
||||||
|
|
||||||
|
fun getExpiresAt(): String? = prefs.getString(KEY_EXPIRES_AT, null)
|
||||||
|
|
||||||
|
fun clearToken() {
|
||||||
|
prefs.edit()
|
||||||
|
.remove(KEY_TOKEN)
|
||||||
|
.remove(KEY_EXPIRES_AT)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isLoggedIn(): Boolean = getToken() != null
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val KEY_TOKEN = "jwt_token"
|
||||||
|
private const val KEY_EXPIRES_AT = "jwt_expires_at"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.pantree.app.data.local.dao
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import com.pantree.app.data.local.entity.PantryItemEntity
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface PantryDao {
|
||||||
|
@Query("SELECT * FROM pantry_items ORDER BY item_name ASC")
|
||||||
|
fun getAllItems(): Flow<List<PantryItemEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM pantry_items WHERE id = :id")
|
||||||
|
suspend fun getItemById(id: String): PantryItemEntity?
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertItem(item: PantryItemEntity)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertItems(items: List<PantryItemEntity>)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun updateItem(item: PantryItemEntity)
|
||||||
|
|
||||||
|
@Query("DELETE FROM pantry_items WHERE id = :id")
|
||||||
|
suspend fun deleteItemById(id: String)
|
||||||
|
|
||||||
|
@Query("DELETE FROM pantry_items")
|
||||||
|
suspend fun deleteAll()
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.pantree.app.data.local.dao
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import com.pantree.app.data.local.entity.RecipeEntity
|
||||||
|
import com.pantree.app.data.local.entity.RecipeIngredientEntity
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface RecipeDao {
|
||||||
|
@Query("SELECT * FROM recipes ORDER BY name ASC")
|
||||||
|
fun getAllRecipes(): Flow<List<RecipeEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM recipes WHERE can_make = 1 ORDER BY name ASC")
|
||||||
|
fun getCanMakeRecipes(): Flow<List<RecipeEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM recipes WHERE can_make = 0 AND available_ingredient_count > 0 ORDER BY availability_percentage DESC")
|
||||||
|
fun getPartialRecipes(): Flow<List<RecipeEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM recipe_ingredients WHERE recipe_id = :recipeId")
|
||||||
|
suspend fun getIngredientsForRecipe(recipeId: String): List<RecipeIngredientEntity>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertRecipes(recipes: List<RecipeEntity>)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertIngredients(ingredients: List<RecipeIngredientEntity>)
|
||||||
|
|
||||||
|
@Query("DELETE FROM recipes")
|
||||||
|
suspend fun deleteAll()
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.pantree.app.data.local.dao
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import com.pantree.app.data.local.entity.ShoppingListEntity
|
||||||
|
import com.pantree.app.data.local.entity.ShoppingListItemEntity
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface ShoppingListDao {
|
||||||
|
@Query("SELECT * FROM shopping_lists ORDER BY last_modified DESC")
|
||||||
|
fun getAllLists(): Flow<List<ShoppingListEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM shopping_list_items WHERE shopping_list_id = :listId ORDER BY item_name ASC")
|
||||||
|
fun getItemsForList(listId: String): Flow<List<ShoppingListItemEntity>>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertList(list: ShoppingListEntity)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertLists(lists: List<ShoppingListEntity>)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertItem(item: ShoppingListItemEntity)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertItems(items: List<ShoppingListItemEntity>)
|
||||||
|
|
||||||
|
@Query("DELETE FROM shopping_lists WHERE id = :id")
|
||||||
|
suspend fun deleteListById(id: String)
|
||||||
|
|
||||||
|
@Query("DELETE FROM shopping_list_items WHERE id = :id")
|
||||||
|
suspend fun deleteItemById(id: String)
|
||||||
|
|
||||||
|
@Query("DELETE FROM shopping_list_items WHERE shopping_list_id = :listId")
|
||||||
|
suspend fun deleteItemsForList(listId: String)
|
||||||
|
|
||||||
|
@Query("DELETE FROM shopping_lists")
|
||||||
|
suspend fun deleteAll()
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.pantree.app.data.local.entity
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(tableName = "pantry_items")
|
||||||
|
data class PantryItemEntity(
|
||||||
|
@PrimaryKey val id: String,
|
||||||
|
@ColumnInfo(name = "item_name") val itemName: String,
|
||||||
|
val quantity: Int,
|
||||||
|
@ColumnInfo(name = "last_modified") val lastModified: String,
|
||||||
|
@ColumnInfo(name = "created_at") val createdAt: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "recipes")
|
||||||
|
data class RecipeEntity(
|
||||||
|
@PrimaryKey val id: String,
|
||||||
|
val name: String,
|
||||||
|
val servings: Int,
|
||||||
|
@ColumnInfo(name = "ingredient_count") val ingredientCount: Int,
|
||||||
|
@ColumnInfo(name = "available_ingredient_count") val availableIngredientCount: Int,
|
||||||
|
@ColumnInfo(name = "can_make") val canMake: Boolean,
|
||||||
|
@ColumnInfo(name = "availability_percentage") val availabilityPercentage: Double
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "recipe_ingredients",
|
||||||
|
foreignKeys = [ForeignKey(
|
||||||
|
entity = RecipeEntity::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["recipe_id"],
|
||||||
|
onDelete = ForeignKey.CASCADE
|
||||||
|
)],
|
||||||
|
indices = [Index("recipe_id")]
|
||||||
|
)
|
||||||
|
data class RecipeIngredientEntity(
|
||||||
|
@PrimaryKey val id: String,
|
||||||
|
@ColumnInfo(name = "recipe_id") val recipeId: String,
|
||||||
|
@ColumnInfo(name = "item_name") val itemName: String,
|
||||||
|
val quantity: Double,
|
||||||
|
@ColumnInfo(name = "original_quantity") val originalQuantity: Double,
|
||||||
|
val unit: String,
|
||||||
|
@ColumnInfo(name = "in_pantry") val inPantry: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "shopping_lists")
|
||||||
|
data class ShoppingListEntity(
|
||||||
|
@PrimaryKey val id: String,
|
||||||
|
@ColumnInfo(name = "list_name") val listName: String,
|
||||||
|
@ColumnInfo(name = "item_count") val itemCount: Int,
|
||||||
|
@ColumnInfo(name = "checked_count") val checkedCount: Int,
|
||||||
|
@ColumnInfo(name = "last_modified") val lastModified: String,
|
||||||
|
@ColumnInfo(name = "created_at") val createdAt: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "shopping_list_items",
|
||||||
|
foreignKeys = [ForeignKey(
|
||||||
|
entity = ShoppingListEntity::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["shopping_list_id"],
|
||||||
|
onDelete = ForeignKey.CASCADE
|
||||||
|
)],
|
||||||
|
indices = [Index("shopping_list_id")]
|
||||||
|
)
|
||||||
|
data class ShoppingListItemEntity(
|
||||||
|
@PrimaryKey val id: String,
|
||||||
|
@ColumnInfo(name = "shopping_list_id") val shoppingListId: String,
|
||||||
|
@ColumnInfo(name = "item_name") val itemName: String,
|
||||||
|
val quantity: Double,
|
||||||
|
val unit: String,
|
||||||
|
@ColumnInfo(name = "checked_off") val checkedOff: Boolean,
|
||||||
|
@ColumnInfo(name = "last_modified") val lastModified: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
package com.pantree.app.data.model
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
data class SignupRequest(
|
||||||
|
val email: String,
|
||||||
|
val password: String,
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SigninRequest(
|
||||||
|
val email: String,
|
||||||
|
val password: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GoogleAuthRequest(
|
||||||
|
@SerializedName("id_token") val idToken: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PasswordResetRequest(
|
||||||
|
val email: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ConfirmPasswordResetRequest(
|
||||||
|
val token: String,
|
||||||
|
@SerializedName("new_password") val newPassword: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AuthResponse(
|
||||||
|
val user: UserDto,
|
||||||
|
val token: String,
|
||||||
|
@SerializedName("expires_at") val expiresAt: String,
|
||||||
|
@SerializedName("is_new_user") val isNewUser: Boolean? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UserDto(
|
||||||
|
val id: String,
|
||||||
|
val email: String,
|
||||||
|
val name: String,
|
||||||
|
@SerializedName("profile_picture_url") val profilePictureUrl: String?,
|
||||||
|
@SerializedName("deleted_at") val deletedAt: String?,
|
||||||
|
@SerializedName("created_at") val createdAt: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MessageResponse(
|
||||||
|
val message: String,
|
||||||
|
val timestamp: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RestoreAccountResponse(
|
||||||
|
val user: UserDto,
|
||||||
|
val message: String,
|
||||||
|
val timestamp: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ApiError(
|
||||||
|
val error: String,
|
||||||
|
val code: String,
|
||||||
|
val timestamp: String,
|
||||||
|
@SerializedName("deletion_scheduled_at") val deletionScheduledAt: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Pantry ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
data class AddPantryItemRequest(
|
||||||
|
@SerializedName("item_name") val itemName: String,
|
||||||
|
val quantity: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UpdatePantryItemRequest(
|
||||||
|
val quantity: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PantryItemDto(
|
||||||
|
val id: String,
|
||||||
|
@SerializedName("item_name") val itemName: String,
|
||||||
|
val quantity: Int,
|
||||||
|
@SerializedName("last_modified") val lastModified: String,
|
||||||
|
@SerializedName("created_at") val createdAt: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PantryListResponse(
|
||||||
|
val items: List<PantryItemDto>,
|
||||||
|
@SerializedName("synced_at") val syncedAt: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PantryItemResponse(
|
||||||
|
val item: PantryItemDto,
|
||||||
|
@SerializedName("synced_at") val syncedAt: String
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Recipes ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
data class RecipeSummaryDto(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val servings: Int,
|
||||||
|
@SerializedName("ingredient_count") val ingredientCount: Int,
|
||||||
|
@SerializedName("available_ingredient_count") val availableIngredientCount: Int,
|
||||||
|
@SerializedName("can_make") val canMake: Boolean,
|
||||||
|
@SerializedName("availability_percentage") val availabilityPercentage: Double
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RecipeIngredientDto(
|
||||||
|
val id: String,
|
||||||
|
@SerializedName("item_name") val itemName: String,
|
||||||
|
val quantity: Double,
|
||||||
|
@SerializedName("original_quantity") val originalQuantity: Double,
|
||||||
|
val unit: String,
|
||||||
|
@SerializedName("in_pantry") val inPantry: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RecipeDetailDto(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val servings: Int,
|
||||||
|
@SerializedName("scaled_servings") val scaledServings: Int,
|
||||||
|
@SerializedName("scale_factor") val scaleFactor: Int,
|
||||||
|
val instructions: String,
|
||||||
|
val ingredients: List<RecipeIngredientDto>,
|
||||||
|
@SerializedName("can_make") val canMake: Boolean,
|
||||||
|
@SerializedName("available_ingredient_count") val availableIngredientCount: Int,
|
||||||
|
@SerializedName("ingredient_count") val ingredientCount: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PaginationDto(
|
||||||
|
val page: Int,
|
||||||
|
val limit: Int,
|
||||||
|
val total: Int,
|
||||||
|
@SerializedName("total_pages") val totalPages: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RecipeListResponse(
|
||||||
|
val recipes: List<RecipeSummaryDto>,
|
||||||
|
val pagination: PaginationDto,
|
||||||
|
@SerializedName("synced_at") val syncedAt: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RecipeDetailResponse(
|
||||||
|
val recipe: RecipeDetailDto
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Shopping Lists ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
data class CreateShoppingListRequest(
|
||||||
|
@SerializedName("list_name") val listName: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AddShoppingListItemRequest(
|
||||||
|
@SerializedName("item_name") val itemName: String,
|
||||||
|
val quantity: Double,
|
||||||
|
val unit: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UpdateShoppingListItemRequest(
|
||||||
|
val quantity: Double? = null,
|
||||||
|
val unit: String? = null,
|
||||||
|
@SerializedName("checked_off") val checkedOff: Boolean? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AddRecipesToListRequest(
|
||||||
|
@SerializedName("recipe_ids") val recipeIds: List<String>,
|
||||||
|
@SerializedName("scale_factor") val scaleFactor: Int = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ShoppingListSummaryDto(
|
||||||
|
val id: String,
|
||||||
|
@SerializedName("list_name") val listName: String,
|
||||||
|
@SerializedName("item_count") val itemCount: Int,
|
||||||
|
@SerializedName("checked_count") val checkedCount: Int,
|
||||||
|
@SerializedName("last_modified") val lastModified: String,
|
||||||
|
@SerializedName("created_at") val createdAt: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ShoppingListItemDto(
|
||||||
|
val id: String,
|
||||||
|
@SerializedName("item_name") val itemName: String,
|
||||||
|
val quantity: Double,
|
||||||
|
val unit: String,
|
||||||
|
@SerializedName("checked_off") val checkedOff: Boolean,
|
||||||
|
@SerializedName("last_modified") val lastModified: String,
|
||||||
|
val merged: Boolean? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ShoppingListDetailDto(
|
||||||
|
val id: String,
|
||||||
|
@SerializedName("list_name") val listName: String,
|
||||||
|
@SerializedName("last_modified") val lastModified: String,
|
||||||
|
@SerializedName("created_at") val createdAt: String,
|
||||||
|
val items: List<ShoppingListItemDto>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ShoppingListsResponse(
|
||||||
|
@SerializedName("shopping_lists") val shoppingLists: List<ShoppingListSummaryDto>,
|
||||||
|
@SerializedName("synced_at") val syncedAt: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ShoppingListResponse(
|
||||||
|
@SerializedName("shopping_list") val shoppingList: ShoppingListSummaryDto
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ShoppingListDetailResponse(
|
||||||
|
@SerializedName("shopping_list") val shoppingList: ShoppingListDetailDto,
|
||||||
|
@SerializedName("synced_at") val syncedAt: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ShoppingListItemResponse(
|
||||||
|
val item: ShoppingListItemDto,
|
||||||
|
@SerializedName("synced_at") val syncedAt: String? = null,
|
||||||
|
val merged: Boolean? = null,
|
||||||
|
@SerializedName("previous_quantity") val previousQuantity: Double? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AddRecipesResponse(
|
||||||
|
@SerializedName("shopping_list") val shoppingList: ShoppingListDetailDto,
|
||||||
|
@SerializedName("recipes_added") val recipesAdded: Int,
|
||||||
|
@SerializedName("items_merged") val itemsMerged: Int,
|
||||||
|
@SerializedName("items_created") val itemsCreated: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Sync ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
data class SyncPantryDto(
|
||||||
|
val updated: List<PantryItemDto>,
|
||||||
|
val deleted: List<String>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SyncListItemsDto(
|
||||||
|
val updated: List<ShoppingListItemDto>,
|
||||||
|
val deleted: List<String>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SyncShoppingListDto(
|
||||||
|
val id: String,
|
||||||
|
@SerializedName("list_name") val listName: String,
|
||||||
|
@SerializedName("last_modified") val lastModified: String,
|
||||||
|
val items: SyncListItemsDto
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SyncShoppingListsDto(
|
||||||
|
val updated: List<SyncShoppingListDto>,
|
||||||
|
val deleted: List<String>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SyncResponse(
|
||||||
|
@SerializedName("server_timestamp") val serverTimestamp: String,
|
||||||
|
val pantry: SyncPantryDto,
|
||||||
|
@SerializedName("shopping_lists") val shoppingLists: SyncShoppingListsDto
|
||||||
|
)
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package com.pantree.app.data.remote
|
||||||
|
|
||||||
|
import com.pantree.app.data.model.*
|
||||||
|
import retrofit2.Response
|
||||||
|
import retrofit2.http.*
|
||||||
|
|
||||||
|
interface ApiService {
|
||||||
|
|
||||||
|
// ── Auth ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@POST("auth/signup")
|
||||||
|
suspend fun signup(@Body request: SignupRequest): Response<AuthResponse>
|
||||||
|
|
||||||
|
@POST("auth/signin")
|
||||||
|
suspend fun signin(@Body request: SigninRequest): Response<AuthResponse>
|
||||||
|
|
||||||
|
@POST("auth/google")
|
||||||
|
suspend fun googleAuth(@Body request: GoogleAuthRequest): Response<AuthResponse>
|
||||||
|
|
||||||
|
@POST("auth/password-reset")
|
||||||
|
suspend fun requestPasswordReset(@Body request: PasswordResetRequest): Response<MessageResponse>
|
||||||
|
|
||||||
|
@PUT("auth/password-reset")
|
||||||
|
suspend fun confirmPasswordReset(@Body request: ConfirmPasswordResetRequest): Response<MessageResponse>
|
||||||
|
|
||||||
|
@DELETE("auth/account")
|
||||||
|
suspend fun deleteAccount(): Response<Unit>
|
||||||
|
|
||||||
|
@POST("auth/restore-account")
|
||||||
|
suspend fun restoreAccount(): Response<RestoreAccountResponse>
|
||||||
|
|
||||||
|
// ── Pantry ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GET("pantry")
|
||||||
|
suspend fun getPantryItems(): Response<PantryListResponse>
|
||||||
|
|
||||||
|
@POST("pantry")
|
||||||
|
suspend fun addPantryItem(@Body request: AddPantryItemRequest): Response<PantryItemResponse>
|
||||||
|
|
||||||
|
@PUT("pantry/{itemId}")
|
||||||
|
suspend fun updatePantryItem(
|
||||||
|
@Path("itemId") itemId: String,
|
||||||
|
@Body request: UpdatePantryItemRequest
|
||||||
|
): Response<PantryItemResponse>
|
||||||
|
|
||||||
|
@DELETE("pantry/{itemId}")
|
||||||
|
suspend fun deletePantryItem(@Path("itemId") itemId: String): Response<Unit>
|
||||||
|
|
||||||
|
// ── Recipes ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GET("recipes")
|
||||||
|
suspend fun getRecipes(
|
||||||
|
@Query("filter") filter: String = "all",
|
||||||
|
@Query("page") page: Int = 1,
|
||||||
|
@Query("limit") limit: Int = 20,
|
||||||
|
@Query("search") search: String? = null
|
||||||
|
): Response<RecipeListResponse>
|
||||||
|
|
||||||
|
@GET("recipes/{recipeId}")
|
||||||
|
suspend fun getRecipeById(
|
||||||
|
@Path("recipeId") recipeId: String,
|
||||||
|
@Query("scale") scale: Int = 1
|
||||||
|
): Response<RecipeDetailResponse>
|
||||||
|
|
||||||
|
// ── Shopping Lists ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GET("shopping-lists")
|
||||||
|
suspend fun getShoppingLists(): Response<ShoppingListsResponse>
|
||||||
|
|
||||||
|
@POST("shopping-lists")
|
||||||
|
suspend fun createShoppingList(@Body request: CreateShoppingListRequest): Response<ShoppingListResponse>
|
||||||
|
|
||||||
|
@GET("shopping-lists/{listId}")
|
||||||
|
suspend fun getShoppingListById(@Path("listId") listId: String): Response<ShoppingListDetailResponse>
|
||||||
|
|
||||||
|
@DELETE("shopping-lists/{listId}")
|
||||||
|
suspend fun deleteShoppingList(@Path("listId") listId: String): Response<Unit>
|
||||||
|
|
||||||
|
@POST("shopping-lists/{listId}/items")
|
||||||
|
suspend fun addShoppingListItem(
|
||||||
|
@Path("listId") listId: String,
|
||||||
|
@Body request: AddShoppingListItemRequest
|
||||||
|
): Response<ShoppingListItemResponse>
|
||||||
|
|
||||||
|
@POST("shopping-lists/{listId}/add-recipes")
|
||||||
|
suspend fun addRecipesToShoppingList(
|
||||||
|
@Path("listId") listId: String,
|
||||||
|
@Body request: AddRecipesToListRequest
|
||||||
|
): Response<AddRecipesResponse>
|
||||||
|
|
||||||
|
@PUT("shopping-lists/{listId}/items/{itemId}")
|
||||||
|
suspend fun updateShoppingListItem(
|
||||||
|
@Path("listId") listId: String,
|
||||||
|
@Path("itemId") itemId: String,
|
||||||
|
@Body request: UpdateShoppingListItemRequest
|
||||||
|
): Response<ShoppingListItemResponse>
|
||||||
|
|
||||||
|
@DELETE("shopping-lists/{listId}/items/{itemId}")
|
||||||
|
suspend fun deleteShoppingListItem(
|
||||||
|
@Path("listId") listId: String,
|
||||||
|
@Path("itemId") itemId: String
|
||||||
|
): Response<Unit>
|
||||||
|
|
||||||
|
// ── Sync ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GET("sync")
|
||||||
|
suspend fun sync(@Query("since") since: String): Response<SyncResponse>
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.pantree.app.data.remote
|
||||||
|
|
||||||
|
import com.pantree.app.data.local.TokenStore
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class AuthInterceptor @Inject constructor(
|
||||||
|
private val tokenStore: TokenStore
|
||||||
|
) : Interceptor {
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val token = tokenStore.getToken()
|
||||||
|
val request = if (token != null) {
|
||||||
|
chain.request().newBuilder()
|
||||||
|
.addHeader("Authorization", "Bearer $token")
|
||||||
|
.build()
|
||||||
|
} else {
|
||||||
|
chain.request()
|
||||||
|
}
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.pantree.app.data.repository
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.pantree.app.data.model.ApiError
|
||||||
|
import com.pantree.app.data.model.AuthResponse
|
||||||
|
import com.pantree.app.data.model.ConfirmPasswordResetRequest
|
||||||
|
import com.pantree.app.data.model.GoogleAuthRequest
|
||||||
|
import com.pantree.app.data.model.MessageResponse
|
||||||
|
import com.pantree.app.data.model.PasswordResetRequest
|
||||||
|
import com.pantree.app.data.model.RestoreAccountResponse
|
||||||
|
import com.pantree.app.data.model.SigninRequest
|
||||||
|
import com.pantree.app.data.model.SignupRequest
|
||||||
|
import com.pantree.app.data.local.TokenStore
|
||||||
|
import com.pantree.app.data.remote.ApiService
|
||||||
|
import com.pantree.app.util.Result
|
||||||
|
import com.pantree.app.util.safeApiCall
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class AuthRepository @Inject constructor(
|
||||||
|
private val api: ApiService,
|
||||||
|
private val tokenStore: TokenStore,
|
||||||
|
private val gson: Gson
|
||||||
|
) {
|
||||||
|
suspend fun signup(email: String, password: String, name: String): Result<AuthResponse> {
|
||||||
|
val result = safeApiCall(gson) { api.signup(SignupRequest(email, password, name)) }
|
||||||
|
if (result is Result.Success) {
|
||||||
|
tokenStore.saveToken(result.data.token, result.data.expiresAt)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun signin(email: String, password: String): Result<AuthResponse> {
|
||||||
|
val result = safeApiCall(gson) { api.signin(SigninRequest(email, password)) }
|
||||||
|
if (result is Result.Success) {
|
||||||
|
tokenStore.saveToken(result.data.token, result.data.expiresAt)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun googleAuth(idToken: String): Result<AuthResponse> {
|
||||||
|
val result = safeApiCall(gson) { api.googleAuth(GoogleAuthRequest(idToken)) }
|
||||||
|
if (result is Result.Success) {
|
||||||
|
tokenStore.saveToken(result.data.token, result.data.expiresAt)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun requestPasswordReset(email: String): Result<MessageResponse> =
|
||||||
|
safeApiCall(gson) { api.requestPasswordReset(PasswordResetRequest(email)) }
|
||||||
|
|
||||||
|
suspend fun confirmPasswordReset(token: String, newPassword: String): Result<MessageResponse> =
|
||||||
|
safeApiCall(gson) { api.confirmPasswordReset(ConfirmPasswordResetRequest(token, newPassword)) }
|
||||||
|
|
||||||
|
suspend fun deleteAccount(): Result<Unit> =
|
||||||
|
safeApiCall(gson) { api.deleteAccount() }
|
||||||
|
|
||||||
|
suspend fun restoreAccount(): Result<RestoreAccountResponse> =
|
||||||
|
safeApiCall(gson) { api.restoreAccount() }
|
||||||
|
|
||||||
|
fun logout() = tokenStore.clearToken()
|
||||||
|
fun isLoggedIn() = tokenStore.isLoggedIn()
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.pantree.app.data.repository
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.pantree.app.data.local.dao.PantryDao
|
||||||
|
import com.pantree.app.data.local.entity.PantryItemEntity
|
||||||
|
import com.pantree.app.data.model.AddPantryItemRequest
|
||||||
|
import com.pantree.app.data.model.PantryItemDto
|
||||||
|
import com.pantree.app.data.model.PantryItemResponse
|
||||||
|
import com.pantree.app.data.model.PantryListResponse
|
||||||
|
import com.pantree.app.data.model.UpdatePantryItemRequest
|
||||||
|
import com.pantree.app.data.remote.ApiService
|
||||||
|
import com.pantree.app.util.Result
|
||||||
|
import com.pantree.app.util.safeApiCall
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class PantryRepository @Inject constructor(
|
||||||
|
private val api: ApiService,
|
||||||
|
private val pantryDao: PantryDao,
|
||||||
|
private val gson: Gson
|
||||||
|
) {
|
||||||
|
fun getLocalItems(): Flow<List<PantryItemEntity>> = pantryDao.getAllItems()
|
||||||
|
|
||||||
|
suspend fun fetchAndCacheItems(): Result<PantryListResponse> {
|
||||||
|
val result = safeApiCall(gson) { api.getPantryItems() }
|
||||||
|
if (result is Result.Success) {
|
||||||
|
pantryDao.deleteAll()
|
||||||
|
pantryDao.insertItems(result.data.items.map { it.toEntity() })
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addItem(name: String, quantity: Int): Result<PantryItemResponse> {
|
||||||
|
val result = safeApiCall(gson) { api.addPantryItem(AddPantryItemRequest(name, quantity)) }
|
||||||
|
if (result is Result.Success) {
|
||||||
|
pantryDao.insertItem(result.data.item.toEntity())
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateItem(id: String, quantity: Int): Result<PantryItemResponse> {
|
||||||
|
val result = safeApiCall(gson) { api.updatePantryItem(id, UpdatePantryItemRequest(quantity)) }
|
||||||
|
if (result is Result.Success) {
|
||||||
|
pantryDao.insertItem(result.data.item.toEntity())
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteItem(id: String): Result<Unit> {
|
||||||
|
val result = safeApiCall(gson) { api.deletePantryItem(id) }
|
||||||
|
if (result is Result.Success) {
|
||||||
|
pantryDao.deleteItemById(id)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PantryItemDto.toEntity() = PantryItemEntity(
|
||||||
|
id = id, itemName = itemName, quantity = quantity,
|
||||||
|
lastModified = lastModified, createdAt = createdAt
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.pantree.app.data.repository
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.pantree.app.data.local.dao.RecipeDao
|
||||||
|
import com.pantree.app.data.local.entity.RecipeEntity
|
||||||
|
import com.pantree.app.data.local.entity.RecipeIngredientEntity
|
||||||
|
import com.pantree.app.data.model.RecipeDetailResponse
|
||||||
|
import com.pantree.app.data.model.RecipeListResponse
|
||||||
|
import com.pantree.app.data.model.RecipeSummaryDto
|
||||||
|
import com.pantree.app.data.remote.ApiService
|
||||||
|
import com.pantree.app.util.Result
|
||||||
|
import com.pantree.app.util.safeApiCall
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class RecipeRepository @Inject constructor(
|
||||||
|
private val api: ApiService,
|
||||||
|
private val recipeDao: RecipeDao,
|
||||||
|
private val gson: Gson
|
||||||
|
) {
|
||||||
|
fun getLocalRecipes(): Flow<List<RecipeEntity>> = recipeDao.getAllRecipes()
|
||||||
|
fun getCanMakeRecipes(): Flow<List<RecipeEntity>> = recipeDao.getCanMakeRecipes()
|
||||||
|
fun getPartialRecipes(): Flow<List<RecipeEntity>> = recipeDao.getPartialRecipes()
|
||||||
|
|
||||||
|
suspend fun fetchRecipes(
|
||||||
|
filter: String = "all",
|
||||||
|
page: Int = 1,
|
||||||
|
search: String? = null
|
||||||
|
): Result<RecipeListResponse> {
|
||||||
|
val result = safeApiCall(gson) { api.getRecipes(filter, page, 20, search) }
|
||||||
|
if (result is Result.Success && page == 1) {
|
||||||
|
recipeDao.deleteAll()
|
||||||
|
recipeDao.insertRecipes(result.data.recipes.map { it.toEntity() })
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getRecipeById(id: String, scale: Int = 1): Result<RecipeDetailResponse> =
|
||||||
|
safeApiCall(gson) { api.getRecipeById(id, scale) }
|
||||||
|
|
||||||
|
private fun RecipeSummaryDto.toEntity() = RecipeEntity(
|
||||||
|
id = id, name = name, servings = servings,
|
||||||
|
ingredientCount = ingredientCount,
|
||||||
|
availableIngredientCount = availableIngredientCount,
|
||||||
|
canMake = canMake,
|
||||||
|
availabilityPercentage = availabilityPercentage
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package com.pantree.app.data.repository
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.pantree.app.data.local.dao.ShoppingListDao
|
||||||
|
import com.pantree.app.data.local.entity.ShoppingListEntity
|
||||||
|
import com.pantree.app.data.local.entity.ShoppingListItemEntity
|
||||||
|
import com.pantree.app.data.model.*
|
||||||
|
import com.pantree.app.data.remote.ApiService
|
||||||
|
import com.pantree.app.util.Result
|
||||||
|
import com.pantree.app.util.safeApiCall
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ShoppingListRepository @Inject constructor(
|
||||||
|
private val api: ApiService,
|
||||||
|
private val shoppingListDao: ShoppingListDao,
|
||||||
|
private val gson: Gson
|
||||||
|
) {
|
||||||
|
fun getLocalLists(): Flow<List<ShoppingListEntity>> = shoppingListDao.getAllLists()
|
||||||
|
fun getLocalItems(listId: String): Flow<List<ShoppingListItemEntity>> =
|
||||||
|
shoppingListDao.getItemsForList(listId)
|
||||||
|
|
||||||
|
suspend fun fetchLists(): Result<ShoppingListsResponse> {
|
||||||
|
val result = safeApiCall(gson) { api.getShoppingLists() }
|
||||||
|
if (result is Result.Success) {
|
||||||
|
shoppingListDao.deleteAll()
|
||||||
|
shoppingListDao.insertLists(result.data.shoppingLists.map { it.toEntity() })
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createList(name: String): Result<ShoppingListResponse> {
|
||||||
|
val result = safeApiCall(gson) { api.createShoppingList(CreateShoppingListRequest(name)) }
|
||||||
|
if (result is Result.Success) {
|
||||||
|
shoppingListDao.insertList(result.data.shoppingList.toEntity())
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchListById(listId: String): Result<ShoppingListDetailResponse> {
|
||||||
|
val result = safeApiCall(gson) { api.getShoppingListById(listId) }
|
||||||
|
if (result is Result.Success) {
|
||||||
|
shoppingListDao.deleteItemsForList(listId)
|
||||||
|
shoppingListDao.insertItems(result.data.shoppingList.items.map { it.toEntity(listId) })
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteList(listId: String): Result<Unit> {
|
||||||
|
val result = safeApiCall(gson) { api.deleteShoppingList(listId) }
|
||||||
|
if (result is Result.Success) shoppingListDao.deleteListById(listId)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addItem(listId: String, name: String, quantity: Double, unit: String): Result<ShoppingListItemResponse> {
|
||||||
|
val result = safeApiCall(gson) { api.addShoppingListItem(listId, AddShoppingListItemRequest(name, quantity, unit)) }
|
||||||
|
if (result is Result.Success) shoppingListDao.insertItem(result.data.item.toEntity(listId))
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addRecipesToList(listId: String, recipeIds: List<String>, scaleFactor: Int): Result<AddRecipesResponse> {
|
||||||
|
val result = safeApiCall(gson) { api.addRecipesToShoppingList(listId, AddRecipesToListRequest(recipeIds, scaleFactor)) }
|
||||||
|
if (result is Result.Success) {
|
||||||
|
shoppingListDao.deleteItemsForList(listId)
|
||||||
|
shoppingListDao.insertItems(result.data.shoppingList.items.map { it.toEntity(listId) })
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateItem(listId: String, itemId: String, quantity: Double? = null, unit: String? = null, checkedOff: Boolean? = null): Result<ShoppingListItemResponse> {
|
||||||
|
val result = safeApiCall(gson) { api.updateShoppingListItem(listId, itemId, UpdateShoppingListItemRequest(quantity, unit, checkedOff)) }
|
||||||
|
if (result is Result.Success) shoppingListDao.insertItem(result.data.item.toEntity(listId))
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteItem(listId: String, itemId: String): Result<Unit> {
|
||||||
|
val result = safeApiCall(gson) { api.deleteShoppingListItem(listId, itemId) }
|
||||||
|
if (result is Result.Success) shoppingListDao.deleteItemById(itemId)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ShoppingListSummaryDto.toEntity() = ShoppingListEntity(
|
||||||
|
id = id, listName = listName, itemCount = itemCount,
|
||||||
|
checkedCount = checkedCount, lastModified = lastModified, createdAt = createdAt
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun ShoppingListItemDto.toEntity(listId: String) = ShoppingListItemEntity(
|
||||||
|
id = id, shoppingListId = listId, itemName = itemName,
|
||||||
|
quantity = quantity, unit = unit, checkedOff = checkedOff, lastModified = lastModified
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.pantree.app.data.repository
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.pantree.app.data.local.SyncPreferences
|
||||||
|
import com.pantree.app.data.local.dao.PantryDao
|
||||||
|
import com.pantree.app.data.local.dao.ShoppingListDao
|
||||||
|
import com.pantree.app.data.local.entity.PantryItemEntity
|
||||||
|
import com.pantree.app.data.local.entity.ShoppingListEntity
|
||||||
|
import com.pantree.app.data.local.entity.ShoppingListItemEntity
|
||||||
|
import com.pantree.app.data.model.SyncResponse
|
||||||
|
import com.pantree.app.data.remote.ApiService
|
||||||
|
import com.pantree.app.util.Result
|
||||||
|
import com.pantree.app.util.safeApiCall
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class SyncRepository @Inject constructor(
|
||||||
|
private val api: ApiService,
|
||||||
|
private val pantryDao: PantryDao,
|
||||||
|
private val shoppingListDao: ShoppingListDao,
|
||||||
|
private val syncPreferences: SyncPreferences,
|
||||||
|
private val gson: Gson
|
||||||
|
) {
|
||||||
|
suspend fun sync(): Result<SyncResponse> {
|
||||||
|
val since = syncPreferences.lastSyncTimestamp.first()
|
||||||
|
val result = safeApiCall(gson) { api.sync(since) }
|
||||||
|
if (result is Result.Success) {
|
||||||
|
val data = result.data
|
||||||
|
// Apply pantry delta
|
||||||
|
data.pantry.updated.forEach {
|
||||||
|
pantryDao.insertItem(PantryItemEntity(it.id, it.itemName, it.quantity, it.lastModified, it.createdAt))
|
||||||
|
}
|
||||||
|
data.pantry.deleted.forEach { pantryDao.deleteItemById(it) }
|
||||||
|
|
||||||
|
// Apply shopping list delta
|
||||||
|
data.shoppingLists.updated.forEach { list ->
|
||||||
|
shoppingListDao.insertList(
|
||||||
|
ShoppingListEntity(
|
||||||
|
id = list.id, listName = list.listName,
|
||||||
|
itemCount = list.items.updated.size,
|
||||||
|
checkedCount = list.items.updated.count { it.checkedOff },
|
||||||
|
lastModified = list.lastModified, createdAt = list.lastModified
|
||||||
|
)
|
||||||
|
)
|
||||||
|
list.items.updated.forEach { item ->
|
||||||
|
shoppingListDao.insertItem(
|
||||||
|
ShoppingListItemEntity(
|
||||||
|
id = item.id, shoppingListId = list.id,
|
||||||
|
itemName = item.itemName, quantity = item.quantity,
|
||||||
|
unit = item.unit, checkedOff = item.checkedOff,
|
||||||
|
lastModified = item.lastModified
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
list.items.deleted.forEach { shoppingListDao.deleteItemById(it) }
|
||||||
|
}
|
||||||
|
data.shoppingLists.deleted.forEach { shoppingListDao.deleteListById(it) }
|
||||||
|
|
||||||
|
syncPreferences.updateLastSync(data.serverTimestamp)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
75
android/app/src/main/java/com/pantree/app/di/AppModule.kt
Normal file
75
android/app/src/main/java/com/pantree/app/di/AppModule.kt
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package com.pantree.app.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Room
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.GsonBuilder
|
||||||
|
import com.pantree.app.BuildConfig
|
||||||
|
import com.pantree.app.data.local.PantreeDatabase
|
||||||
|
import com.pantree.app.data.local.TokenStore
|
||||||
|
import com.pantree.app.data.local.dao.PantryDao
|
||||||
|
import com.pantree.app.data.local.dao.RecipeDao
|
||||||
|
import com.pantree.app.data.local.dao.ShoppingListDao
|
||||||
|
import com.pantree.app.data.remote.ApiService
|
||||||
|
import com.pantree.app.data.remote.AuthInterceptor
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object AppModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideGson(): Gson = GsonBuilder().create()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient {
|
||||||
|
val logging = HttpLoggingInterceptor().apply {
|
||||||
|
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY
|
||||||
|
else HttpLoggingInterceptor.Level.NONE
|
||||||
|
}
|
||||||
|
return OkHttpClient.Builder()
|
||||||
|
.addInterceptor(authInterceptor)
|
||||||
|
.addInterceptor(logging)
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideRetrofit(okHttpClient: OkHttpClient, gson: Gson): Retrofit =
|
||||||
|
Retrofit.Builder()
|
||||||
|
.baseUrl(BuildConfig.BASE_URL)
|
||||||
|
.client(okHttpClient)
|
||||||
|
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideApiService(retrofit: Retrofit): ApiService =
|
||||||
|
retrofit.create(ApiService::class.java)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideDatabase(@ApplicationContext context: Context): PantreeDatabase =
|
||||||
|
Room.databaseBuilder(context, PantreeDatabase::class.java, "pantree.db")
|
||||||
|
.fallbackToDestructiveMigration()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
@Provides fun providePantryDao(db: PantreeDatabase): PantryDao = db.pantryDao()
|
||||||
|
@Provides fun provideRecipeDao(db: PantreeDatabase): RecipeDao = db.recipeDao()
|
||||||
|
@Provides fun provideShoppingListDao(db: PantreeDatabase): ShoppingListDao = db.shoppingListDao()
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.pantree.app.sync
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import com.pantree.app.data.local.TokenStore
|
||||||
|
import com.pantree.app.data.repository.SyncRepository
|
||||||
|
import com.pantree.app.util.Result
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class SyncManager @Inject constructor(
|
||||||
|
private val syncRepository: SyncRepository,
|
||||||
|
private val tokenStore: TokenStore,
|
||||||
|
@ApplicationContext private val context: Context
|
||||||
|
) : DefaultLifecycleObserver {
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
/** Called on app open (cold start) and foreground return. */
|
||||||
|
override fun onStart(owner: LifecycleOwner) {
|
||||||
|
triggerSync()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun triggerSync() {
|
||||||
|
if (!tokenStore.isLoggedIn()) return
|
||||||
|
scope.launch {
|
||||||
|
when (val result = syncRepository.sync()) {
|
||||||
|
is Result.Success -> { /* Delta applied to Room, UI observes Flow */ }
|
||||||
|
is Result.Error -> { /* Log silently; cached data still shown */ }
|
||||||
|
is Result.NetworkError -> { /* Offline — cached data shown, offline banner visible */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package com.pantree.app.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoadingState(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
message: String = "Loading..."
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ErrorState(
|
||||||
|
message: String,
|
||||||
|
onRetry: (() -> Unit)? = null,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier.fillMaxSize().padding(32.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "\uD83D\uDE15",
|
||||||
|
style = MaterialTheme.typography.headlineLarge
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
if (onRetry != null) {
|
||||||
|
Button(onClick = onRetry) {
|
||||||
|
Text("Try again")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EmptyState(
|
||||||
|
emoji: String,
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
actionLabel: String? = null,
|
||||||
|
onAction: (() -> Unit)? = null,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier.fillMaxSize().padding(32.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Text(text = emoji, style = MaterialTheme.typography.headlineLarge)
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
if (actionLabel != null && onAction != null) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Button(onClick = onAction) {
|
||||||
|
Text(actionLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OfflineBanner(modifier: Modifier = Modifier) {
|
||||||
|
Surface(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "\uD83D\uDCF5 You're offline. Showing cached data.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PantreeTopBar(
|
||||||
|
title: String,
|
||||||
|
onNavigateBack: (() -> Unit)? = null,
|
||||||
|
actions: @Composable RowScope.() -> Unit = {}
|
||||||
|
) {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(title, style = MaterialTheme.typography.titleLarge) },
|
||||||
|
navigationIcon = {
|
||||||
|
if (onNavigateBack != null) {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = androidx.compose.material.icons.Icons.Default.ArrowBack,
|
||||||
|
contentDescription = "Back"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = actions,
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
actionIconContentColor = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package com.pantree.app.ui.navigation
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.navigation.NavType
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.navigation.navArgument
|
||||||
|
import androidx.navigation.navDeepLink
|
||||||
|
import com.pantree.app.ui.screens.auth.ForgotPasswordScreen
|
||||||
|
import com.pantree.app.ui.screens.auth.ResetPasswordScreen
|
||||||
|
import com.pantree.app.ui.screens.auth.SignInScreen
|
||||||
|
import com.pantree.app.ui.screens.auth.SignUpScreen
|
||||||
|
import com.pantree.app.ui.screens.auth.SplashScreen
|
||||||
|
import com.pantree.app.ui.screens.pantry.PantryScreen
|
||||||
|
import com.pantree.app.ui.screens.recipe.RecipeDetailScreen
|
||||||
|
import com.pantree.app.ui.screens.recipe.RecipesScreen
|
||||||
|
import com.pantree.app.ui.screens.settings.SettingsScreen
|
||||||
|
import com.pantree.app.ui.screens.shopping.ShoppingListDetailScreen
|
||||||
|
import com.pantree.app.ui.screens.shopping.ShoppingListsScreen
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PantreeNavGraph() {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
|
||||||
|
NavHost(navController = navController, startDestination = Screen.Splash.route) {
|
||||||
|
|
||||||
|
composable(Screen.Splash.route) {
|
||||||
|
SplashScreen(
|
||||||
|
onNavigateToSignIn = { navController.navigate(Screen.SignIn.route) { popUpTo(Screen.Splash.route) { inclusive = true } } },
|
||||||
|
onNavigateToPantry = { navController.navigate(Screen.Pantry.route) { popUpTo(Screen.Splash.route) { inclusive = true } } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Screen.SignIn.route) {
|
||||||
|
SignInScreen(
|
||||||
|
onSignInSuccess = { navController.navigate(Screen.Pantry.route) { popUpTo(Screen.SignIn.route) { inclusive = true } } },
|
||||||
|
onNavigateToSignUp = { navController.navigate(Screen.SignUp.route) },
|
||||||
|
onNavigateToForgotPassword = { navController.navigate(Screen.ForgotPassword.route) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Screen.SignUp.route) {
|
||||||
|
SignUpScreen(
|
||||||
|
onSignUpSuccess = { navController.navigate(Screen.Pantry.route) { popUpTo(Screen.SignIn.route) { inclusive = true } } },
|
||||||
|
onNavigateBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Screen.ForgotPassword.route) {
|
||||||
|
ForgotPasswordScreen(onNavigateBack = { navController.popBackStack() })
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
route = Screen.ResetPassword.route,
|
||||||
|
arguments = listOf(navArgument("token") { type = NavType.StringType }),
|
||||||
|
deepLinks = listOf(navDeepLink { uriPattern = "https://pantree.app/reset-password?token={token}" })
|
||||||
|
) { backStackEntry ->
|
||||||
|
ResetPasswordScreen(
|
||||||
|
token = backStackEntry.arguments?.getString("token") ?: "",
|
||||||
|
onResetSuccess = { navController.navigate(Screen.SignIn.route) { popUpTo(0) { inclusive = true } } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Screen.Pantry.route) {
|
||||||
|
PantryScreen(
|
||||||
|
onNavigateToRecipes = { navController.navigate(Screen.Recipes.route) },
|
||||||
|
onNavigateToShoppingLists = { navController.navigate(Screen.ShoppingLists.route) },
|
||||||
|
onNavigateToSettings = { navController.navigate(Screen.Settings.route) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Screen.Recipes.route) {
|
||||||
|
RecipesScreen(
|
||||||
|
onRecipeClick = { id -> navController.navigate(Screen.RecipeDetail.createRoute(id)) },
|
||||||
|
onNavigateBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
route = Screen.RecipeDetail.route,
|
||||||
|
arguments = listOf(navArgument("recipeId") { type = NavType.StringType })
|
||||||
|
) { backStackEntry ->
|
||||||
|
RecipeDetailScreen(
|
||||||
|
recipeId = backStackEntry.arguments?.getString("recipeId") ?: "",
|
||||||
|
onNavigateBack = { navController.popBackStack() },
|
||||||
|
onAddToList = { listId -> navController.navigate(Screen.ShoppingListDetail.createRoute(listId)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Screen.ShoppingLists.route) {
|
||||||
|
ShoppingListsScreen(
|
||||||
|
onListClick = { id -> navController.navigate(Screen.ShoppingListDetail.createRoute(id)) },
|
||||||
|
onNavigateBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
route = Screen.ShoppingListDetail.route,
|
||||||
|
arguments = listOf(navArgument("listId") { type = NavType.StringType })
|
||||||
|
) { backStackEntry ->
|
||||||
|
ShoppingListDetailScreen(
|
||||||
|
listId = backStackEntry.arguments?.getString("listId") ?: "",
|
||||||
|
onNavigateBack = { navController.popBackStack() },
|
||||||
|
onNavigateToRecipes = { navController.navigate(Screen.Recipes.route) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Screen.Settings.route) {
|
||||||
|
SettingsScreen(
|
||||||
|
onSignOut = { navController.navigate(Screen.SignIn.route) { popUpTo(0) { inclusive = true } } },
|
||||||
|
onNavigateBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.pantree.app.ui.navigation
|
||||||
|
|
||||||
|
sealed class Screen(val route: String) {
|
||||||
|
// Auth
|
||||||
|
object Splash : Screen("splash")
|
||||||
|
object SignIn : Screen("signin")
|
||||||
|
object SignUp : Screen("signup")
|
||||||
|
object ForgotPassword : Screen("forgot_password")
|
||||||
|
object ResetPassword : Screen("reset_password/{token}") {
|
||||||
|
fun createRoute(token: String) = "reset_password/$token"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main
|
||||||
|
object Pantry : Screen("pantry")
|
||||||
|
object Recipes : Screen("recipes")
|
||||||
|
object RecipeDetail : Screen("recipe/{recipeId}") {
|
||||||
|
fun createRoute(id: String) = "recipe/$id"
|
||||||
|
}
|
||||||
|
object ShoppingLists : Screen("shopping_lists")
|
||||||
|
object ShoppingListDetail : Screen("shopping_list/{listId}") {
|
||||||
|
fun createRoute(id: String) = "shopping_list/$id"
|
||||||
|
}
|
||||||
|
object Settings : Screen("settings")
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package com.pantree.app.ui.screens.auth
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.pantree.app.data.repository.AuthRepository
|
||||||
|
import com.pantree.app.util.Result
|
||||||
|
import com.pantree.app.util.toUserMessage
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class AuthUiState(
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
val success: Boolean = false,
|
||||||
|
val pendingDeletionDate: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class AuthViewModel @Inject constructor(
|
||||||
|
private val authRepository: AuthRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(AuthUiState())
|
||||||
|
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
fun signup(email: String, password: String, name: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = AuthUiState(isLoading = true)
|
||||||
|
when (val result = authRepository.signup(email, password, name)) {
|
||||||
|
is Result.Success -> _uiState.value = AuthUiState(success = true)
|
||||||
|
is Result.Error -> _uiState.value = AuthUiState(error = result.toUserMessage())
|
||||||
|
is Result.NetworkError -> _uiState.value = AuthUiState(error = "No internet connection. Please check your network.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun signin(email: String, password: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = AuthUiState(isLoading = true)
|
||||||
|
when (val result = authRepository.signin(email, password)) {
|
||||||
|
is Result.Success -> _uiState.value = AuthUiState(success = true)
|
||||||
|
is Result.Error -> {
|
||||||
|
val pendingDate = if (result.code == "ACCOUNT_PENDING_DELETION") result.message else null
|
||||||
|
_uiState.value = AuthUiState(error = result.toUserMessage(), pendingDeletionDate = pendingDate)
|
||||||
|
}
|
||||||
|
is Result.NetworkError -> _uiState.value = AuthUiState(error = "No internet connection. Please check your network.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun googleAuth(idToken: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = AuthUiState(isLoading = true)
|
||||||
|
when (val result = authRepository.googleAuth(idToken)) {
|
||||||
|
is Result.Success -> _uiState.value = AuthUiState(success = true)
|
||||||
|
is Result.Error -> _uiState.value = AuthUiState(error = result.toUserMessage())
|
||||||
|
is Result.NetworkError -> _uiState.value = AuthUiState(error = "No internet connection. Please check your network.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestPasswordReset(email: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = AuthUiState(isLoading = true)
|
||||||
|
when (authRepository.requestPasswordReset(email)) {
|
||||||
|
is Result.Success -> _uiState.value = AuthUiState(success = true)
|
||||||
|
is Result.Error -> _uiState.value = AuthUiState(success = true) // Always show success (anti-enumeration)
|
||||||
|
is Result.NetworkError -> _uiState.value = AuthUiState(error = "No internet connection. Please check your network.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun confirmPasswordReset(token: String, newPassword: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = AuthUiState(isLoading = true)
|
||||||
|
when (val result = authRepository.confirmPasswordReset(token, newPassword)) {
|
||||||
|
is Result.Success -> _uiState.value = AuthUiState(success = true)
|
||||||
|
is Result.Error -> _uiState.value = AuthUiState(error = result.toUserMessage())
|
||||||
|
is Result.NetworkError -> _uiState.value = AuthUiState(error = "No internet connection. Please check your network.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearError() {
|
||||||
|
_uiState.value = _uiState.value.copy(error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isLoggedIn() = authRepository.isLoggedIn()
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package com.pantree.app.ui.screens.auth
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ForgotPasswordScreen(
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
viewModel: AuthViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
var email by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Reset password") },
|
||||||
|
navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.Default.ArrowBack, "Back") } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
if (uiState.success) {
|
||||||
|
// Success state
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Text("📬", style = MaterialTheme.typography.headlineLarge)
|
||||||
|
Text("Check your inbox", style = MaterialTheme.typography.titleLarge)
|
||||||
|
Text(
|
||||||
|
"If an account exists for $email, we've sent a reset link. It expires in 1 hour.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
OutlinedButton(onClick = onNavigateBack) { Text("Back to sign in") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Enter your email and we'll send you a reset link.", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
|
||||||
|
if (uiState.error != null) {
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(uiState.error!!, modifier = Modifier.padding(12.dp), color = MaterialTheme.colorScheme.onErrorContainer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = email,
|
||||||
|
onValueChange = { email = it },
|
||||||
|
label = { Text("Email") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.requestPasswordReset(email.trim()) },
|
||||||
|
enabled = !uiState.isLoading && email.isNotBlank(),
|
||||||
|
modifier = Modifier.fillMaxWidth().height(52.dp)
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(20.dp), color = MaterialTheme.colorScheme.onPrimary)
|
||||||
|
} else {
|
||||||
|
Text("Send reset link")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ResetPasswordScreen(
|
||||||
|
token: String,
|
||||||
|
onResetSuccess: () -> Unit,
|
||||||
|
viewModel: AuthViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
var newPassword by remember { mutableStateOf("") }
|
||||||
|
var confirmPassword by remember { mutableStateOf("") }
|
||||||
|
val passwordsMatch = newPassword == confirmPassword && newPassword.isNotEmpty()
|
||||||
|
|
||||||
|
LaunchedEffect(uiState.success) {
|
||||||
|
if (uiState.success) onResetSuccess()
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(topBar = { TopAppBar(title = { Text("New password") }) }) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text("Choose a new password for your account.", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
|
||||||
|
if (uiState.error != null) {
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(uiState.error!!, modifier = Modifier.padding(12.dp), color = MaterialTheme.colorScheme.onErrorContainer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = newPassword,
|
||||||
|
onValueChange = { newPassword = it; viewModel.clearError() },
|
||||||
|
label = { Text("New password") },
|
||||||
|
supportingText = { Text("At least 8 characters") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = confirmPassword,
|
||||||
|
onValueChange = { confirmPassword = it },
|
||||||
|
label = { Text("Confirm password") },
|
||||||
|
isError = confirmPassword.isNotEmpty() && !passwordsMatch,
|
||||||
|
supportingText = { if (confirmPassword.isNotEmpty() && !passwordsMatch) Text("Passwords don't match") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.confirmPasswordReset(token, newPassword) },
|
||||||
|
enabled = !uiState.isLoading && passwordsMatch && newPassword.length >= 8,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(52.dp)
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(20.dp), color = MaterialTheme.colorScheme.onPrimary)
|
||||||
|
} else {
|
||||||
|
Text("Update password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package com.pantree.app.ui.screens.auth
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SignInScreen(
|
||||||
|
onSignInSuccess: () -> Unit,
|
||||||
|
onNavigateToSignUp: () -> Unit,
|
||||||
|
onNavigateToForgotPassword: () -> Unit,
|
||||||
|
viewModel: AuthViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
var email by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
var passwordVisible by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(uiState.success) {
|
||||||
|
if (uiState.success) onSignInSuccess()
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.height(48.dp))
|
||||||
|
Text("🌿", style = MaterialTheme.typography.headlineLarge)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text("Welcome back", style = MaterialTheme.typography.headlineMedium)
|
||||||
|
Text(
|
||||||
|
"Sign in to your pantry",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
|
||||||
|
// Error banner
|
||||||
|
if (uiState.error != null) {
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = uiState.error!!,
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = email,
|
||||||
|
onValueChange = { email = it; viewModel.clearError() },
|
||||||
|
label = { Text("Email") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it; viewModel.clearError() },
|
||||||
|
label = { Text("Password") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||||
|
contentDescription = if (passwordVisible) "Hide password" else "Show password"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
TextButton(
|
||||||
|
onClick = onNavigateToForgotPassword,
|
||||||
|
modifier = Modifier.align(Alignment.End)
|
||||||
|
) {
|
||||||
|
Text("Forgot password?")
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.signin(email.trim(), password) },
|
||||||
|
enabled = !uiState.isLoading && email.isNotBlank() && password.isNotBlank(),
|
||||||
|
modifier = Modifier.fillMaxWidth().height(52.dp)
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(20.dp), color = MaterialTheme.colorScheme.onPrimary)
|
||||||
|
} else {
|
||||||
|
Text("Sign in")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
HorizontalDivider(modifier = Modifier.weight(1f))
|
||||||
|
Text(" or ", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f))
|
||||||
|
HorizontalDivider(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text("Don't have an account?", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
TextButton(onClick = onNavigateToSignUp) { Text("Sign up") }
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(48.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package com.pantree.app.ui.screens.auth
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SignUpScreen(
|
||||||
|
onSignUpSuccess: () -> Unit,
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
viewModel: AuthViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
var email by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
var passwordVisible by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(uiState.success) {
|
||||||
|
if (uiState.success) onSignUpSuccess()
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Create account") },
|
||||||
|
navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.Default.ArrowBack, "Back") } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text("Let's get you set up", style = MaterialTheme.typography.headlineSmall)
|
||||||
|
Text(
|
||||||
|
"Your pantry awaits.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
if (uiState.error != null) {
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = uiState.error!!,
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it; viewModel.clearError() },
|
||||||
|
label = { Text("Full name") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = email,
|
||||||
|
onValueChange = { email = it; viewModel.clearError() },
|
||||||
|
label = { Text("Email") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it; viewModel.clearError() },
|
||||||
|
label = { Text("Password") },
|
||||||
|
supportingText = { Text("At least 8 characters") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.signup(email.trim(), password, name.trim()) },
|
||||||
|
enabled = !uiState.isLoading && name.isNotBlank() && email.isNotBlank() && password.length >= 8,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(52.dp)
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(20.dp), color = MaterialTheme.colorScheme.onPrimary)
|
||||||
|
} else {
|
||||||
|
Text("Create account")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.pantree.app.ui.screens.auth
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SplashScreen(
|
||||||
|
onNavigateToSignIn: () -> Unit,
|
||||||
|
onNavigateToPantry: () -> Unit,
|
||||||
|
viewModel: AuthViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val isLoggedIn = remember { viewModel.isLoggedIn() }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (isLoggedIn) onNavigateToPantry() else onNavigateToSignIn()
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
Text("🌿", style = MaterialTheme.typography.headlineLarge)
|
||||||
|
Text("Pantree", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.primary)
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
package com.pantree.app.ui.screens.pantry
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.pantree.app.data.local.entity.PantryItemEntity
|
||||||
|
import com.pantree.app.ui.components.EmptyState
|
||||||
|
import com.pantree.app.ui.components.ErrorState
|
||||||
|
import com.pantree.app.ui.components.LoadingState
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun PantryScreen(
|
||||||
|
onNavigateToRecipes: () -> Unit,
|
||||||
|
onNavigateToShoppingLists: () -> Unit,
|
||||||
|
onNavigateToSettings: () -> Unit,
|
||||||
|
viewModel: PantryViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
var showAddDialog by remember { mutableStateOf(false) }
|
||||||
|
var editingItem by remember { mutableStateOf<PantryItemEntity?>(null) }
|
||||||
|
var deletingItem by remember { mutableStateOf<PantryItemEntity?>(null) }
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
LaunchedEffect(uiState.actionSuccess) {
|
||||||
|
uiState.actionSuccess?.let {
|
||||||
|
snackbarHostState.showSnackbar(it)
|
||||||
|
viewModel.clearActionMessages()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(uiState.actionError) {
|
||||||
|
uiState.actionError?.let {
|
||||||
|
snackbarHostState.showSnackbar(it)
|
||||||
|
viewModel.clearActionMessages()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("My Pantry") },
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = onNavigateToRecipes) {
|
||||||
|
Icon(Icons.Default.MenuBook, contentDescription = "Recipes", tint = MaterialTheme.colorScheme.onPrimary)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onNavigateToShoppingLists) {
|
||||||
|
Icon(Icons.Default.ShoppingCart, contentDescription = "Shopping Lists", tint = MaterialTheme.colorScheme.onPrimary)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onNavigateToSettings) {
|
||||||
|
Icon(Icons.Default.Settings, contentDescription = "Settings", tint = MaterialTheme.colorScheme.onPrimary)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
FloatingActionButton(onClick = { showAddDialog = true }) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = "Add item")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||||
|
) { padding ->
|
||||||
|
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
|
||||||
|
when {
|
||||||
|
uiState.isLoading && uiState.items.isEmpty() -> LoadingState(message = "Loading your pantry...")
|
||||||
|
uiState.error != null && uiState.items.isEmpty() -> ErrorState(
|
||||||
|
message = uiState.error!!,
|
||||||
|
onRetry = { viewModel.refresh() }
|
||||||
|
)
|
||||||
|
uiState.items.isEmpty() -> EmptyState(
|
||||||
|
emoji = "\uD83E\uDED9",
|
||||||
|
title = "Your pantry is empty",
|
||||||
|
subtitle = "Add ingredients you have on hand and we'll tell you what you can cook.",
|
||||||
|
actionLabel = "Add your first item",
|
||||||
|
onAction = { showAddDialog = true }
|
||||||
|
)
|
||||||
|
else -> {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
"${uiState.items.size} item${if (uiState.items.size != 1) "s" else ""}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
|
||||||
|
modifier = Modifier.padding(bottom = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(uiState.items, key = { it.id }) { item ->
|
||||||
|
PantryItemCard(
|
||||||
|
item = item,
|
||||||
|
onEdit = { editingItem = item },
|
||||||
|
onDelete = { deletingItem = item }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item { Spacer(Modifier.height(80.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showAddDialog) {
|
||||||
|
AddPantryItemDialog(
|
||||||
|
onDismiss = { showAddDialog = false },
|
||||||
|
onConfirm = { name, qty ->
|
||||||
|
viewModel.addItem(name, qty)
|
||||||
|
showAddDialog = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
editingItem?.let { item ->
|
||||||
|
EditPantryItemDialog(
|
||||||
|
item = item,
|
||||||
|
onDismiss = { editingItem = null },
|
||||||
|
onConfirm = { qty ->
|
||||||
|
viewModel.updateItem(item.id, qty)
|
||||||
|
editingItem = null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
deletingItem?.let { item ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { deletingItem = null },
|
||||||
|
title = { Text("Remove item?") },
|
||||||
|
text = { Text("Remove \"${item.itemName}\" from your pantry?") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { viewModel.deleteItem(item.id); deletingItem = null }) {
|
||||||
|
Text("Remove", color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = { deletingItem = null }) { Text("Cancel") } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PantryItemCard(
|
||||||
|
item: PantryItemEntity,
|
||||||
|
onEdit: () -> Unit,
|
||||||
|
onDelete: () -> Unit
|
||||||
|
) {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(item.itemName, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
"Qty: ${item.quantity}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onEdit) {
|
||||||
|
Icon(Icons.Default.Edit, contentDescription = "Edit", tint = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onDelete) {
|
||||||
|
Icon(Icons.Default.Delete, contentDescription = "Delete", tint = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AddPantryItemDialog(onDismiss: () -> Unit, onConfirm: (String, Int) -> Unit) {
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
var quantityText by remember { mutableStateOf("1") }
|
||||||
|
val quantity = quantityText.toIntOrNull()
|
||||||
|
val isValid = name.isNotBlank() && quantity != null && quantity > 0
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Add to pantry") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
label = { Text("Item name") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = quantityText,
|
||||||
|
onValueChange = { quantityText = it },
|
||||||
|
label = { Text("Quantity") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
singleLine = true,
|
||||||
|
isError = quantityText.isNotEmpty() && quantity == null,
|
||||||
|
supportingText = { if (quantityText.isNotEmpty() && quantity == null) Text("Must be a whole number") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { if (isValid) onConfirm(name, quantity!!) }, enabled = isValid) {
|
||||||
|
Text("Add")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EditPantryItemDialog(item: PantryItemEntity, onDismiss: () -> Unit, onConfirm: (Int) -> Unit) {
|
||||||
|
var quantityText by remember { mutableStateOf(item.quantity.toString()) }
|
||||||
|
val quantity = quantityText.toIntOrNull()
|
||||||
|
val isValid = quantity != null && quantity > 0
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Edit quantity") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(item.itemName, style = MaterialTheme.typography.titleMedium)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = quantityText,
|
||||||
|
onValueChange = { quantityText = it },
|
||||||
|
label = { Text("Quantity") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
singleLine = true,
|
||||||
|
isError = quantityText.isNotEmpty() && !isValid,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { if (isValid) onConfirm(quantity!!) }, enabled = isValid) {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package com.pantree.app.ui.screens.pantry
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.pantree.app.data.local.entity.PantryItemEntity
|
||||||
|
import com.pantree.app.data.repository.PantryRepository
|
||||||
|
import com.pantree.app.util.Result
|
||||||
|
import com.pantree.app.util.toUserMessage
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class PantryUiState(
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val items: List<PantryItemEntity> = emptyList(),
|
||||||
|
val error: String? = null,
|
||||||
|
val actionError: String? = null,
|
||||||
|
val actionSuccess: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class PantryViewModel @Inject constructor(
|
||||||
|
private val pantryRepository: PantryRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(PantryUiState(isLoading = true))
|
||||||
|
val uiState: StateFlow<PantryUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Observe local cache immediately
|
||||||
|
viewModelScope.launch {
|
||||||
|
pantryRepository.getLocalItems().collect { items ->
|
||||||
|
_uiState.update { it.copy(items = items, isLoading = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
|
when (val result = pantryRepository.fetchAndCacheItems()) {
|
||||||
|
is Result.Success -> _uiState.update { it.copy(isLoading = false) }
|
||||||
|
is Result.Error -> _uiState.update { it.copy(isLoading = false, error = result.toUserMessage()) }
|
||||||
|
is Result.NetworkError -> _uiState.update { it.copy(isLoading = false, error = null) } // Show cached, offline banner handled elsewhere
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addItem(name: String, quantity: Int) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = pantryRepository.addItem(name.trim(), quantity)) {
|
||||||
|
is Result.Success -> _uiState.update { it.copy(actionSuccess = "${result.data.item.itemName} added to pantry.", actionError = null) }
|
||||||
|
is Result.Error -> _uiState.update { it.copy(actionError = result.toUserMessage()) }
|
||||||
|
is Result.NetworkError -> _uiState.update { it.copy(actionError = "No internet connection.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateItem(id: String, quantity: Int) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = pantryRepository.updateItem(id, quantity)) {
|
||||||
|
is Result.Success -> _uiState.update { it.copy(actionSuccess = "Updated.", actionError = null) }
|
||||||
|
is Result.Error -> _uiState.update { it.copy(actionError = result.toUserMessage()) }
|
||||||
|
is Result.NetworkError -> _uiState.update { it.copy(actionError = "No internet connection.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteItem(id: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = pantryRepository.deleteItem(id)) {
|
||||||
|
is Result.Success -> _uiState.update { it.copy(actionSuccess = "Item removed.", actionError = null) }
|
||||||
|
is Result.Error -> _uiState.update { it.copy(actionError = result.toUserMessage()) }
|
||||||
|
is Result.NetworkError -> _uiState.update { it.copy(actionError = "No internet connection.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearActionMessages() {
|
||||||
|
_uiState.update { it.copy(actionError = null, actionSuccess = null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package com.pantree.app.ui.screens.recipe
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.ShoppingCart
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.pantree.app.data.model.RecipeIngredientDto
|
||||||
|
import com.pantree.app.ui.components.ErrorState
|
||||||
|
import com.pantree.app.ui.components.LoadingState
|
||||||
|
import com.pantree.app.ui.theme.SuccessGreen
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun RecipeDetailScreen(
|
||||||
|
recipeId: String,
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
onAddToList: (String) -> Unit,
|
||||||
|
viewModel: RecipeViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.detailState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
LaunchedEffect(recipeId) {
|
||||||
|
viewModel.loadRecipeDetail(recipeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(uiState.recipe?.name ?: "Recipe") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(Icons.Default.ArrowBack, "Back", tint = MaterialTheme.colorScheme.onPrimary)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
when {
|
||||||
|
uiState.isLoading -> LoadingState(modifier = Modifier.padding(padding), message = "Loading recipe...")
|
||||||
|
uiState.error != null -> ErrorState(
|
||||||
|
modifier = Modifier.padding(padding),
|
||||||
|
message = uiState.error!!,
|
||||||
|
onRetry = { viewModel.loadRecipeDetail(recipeId) }
|
||||||
|
)
|
||||||
|
uiState.recipe != null -> {
|
||||||
|
val recipe = uiState.recipe!!
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Header
|
||||||
|
item {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(recipe.name, style = MaterialTheme.typography.headlineSmall)
|
||||||
|
Text(
|
||||||
|
"${recipe.scaledServings} servings",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (recipe.canMake) {
|
||||||
|
Surface(
|
||||||
|
color = SuccessGreen.copy(alpha = 0.15f),
|
||||||
|
shape = MaterialTheme.shapes.small
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"\u2705 Ready to cook",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = SuccessGreen,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale selector
|
||||||
|
item {
|
||||||
|
Card {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text("Scale recipe", style = MaterialTheme.typography.titleSmall)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
listOf(1, 2, 3).forEach { scale ->
|
||||||
|
FilterChip(
|
||||||
|
selected = uiState.scaleFactor == scale,
|
||||||
|
onClick = { viewModel.setScale(recipeId, scale) },
|
||||||
|
label = { Text("${scale}x") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ingredients
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
"Ingredients (${recipe.availableIngredientCount}/${recipe.ingredientCount} in pantry)",
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(recipe.ingredients) { ingredient ->
|
||||||
|
IngredientRow(ingredient = ingredient)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instructions
|
||||||
|
item {
|
||||||
|
Text("Instructions", style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Card {
|
||||||
|
Text(
|
||||||
|
recipe.instructions,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to shopping list button
|
||||||
|
item {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Button(
|
||||||
|
onClick = { /* TODO: show list picker dialog */ },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.ShoppingCart, contentDescription = null)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Add to shopping list")
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun IngredientRow(ingredient: RecipeIngredientDto) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
if (ingredient.inPantry) SuccessGreen.copy(alpha = 0.07f)
|
||||||
|
else MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
|
.padding(horizontal = 4.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (ingredient.inPantry) "\u2705" else "\u274C",
|
||||||
|
modifier = Modifier.width(28.dp)
|
||||||
|
)
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(ingredient.itemName, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"${formatQuantity(ingredient.quantity)} ${ingredient.unit}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun formatQuantity(qty: Double): String {
|
||||||
|
return if (qty == qty.toLong().toDouble()) qty.toLong().toString()
|
||||||
|
else String.format("%.2f", qty).trimEnd('0').trimEnd('.')
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package com.pantree.app.ui.screens.recipe
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.pantree.app.data.local.entity.RecipeEntity
|
||||||
|
import com.pantree.app.data.model.RecipeDetailDto
|
||||||
|
import com.pantree.app.data.repository.RecipeRepository
|
||||||
|
import com.pantree.app.util.Result
|
||||||
|
import com.pantree.app.util.toUserMessage
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class RecipesUiState(
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val recipes: List<RecipeEntity> = emptyList(),
|
||||||
|
val error: String? = null,
|
||||||
|
val currentFilter: String = "all",
|
||||||
|
val searchQuery: String = "",
|
||||||
|
val currentPage: Int = 1,
|
||||||
|
val totalPages: Int = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RecipeDetailUiState(
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val recipe: RecipeDetailDto? = null,
|
||||||
|
val error: String? = null,
|
||||||
|
val scaleFactor: Int = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class RecipeViewModel @Inject constructor(
|
||||||
|
private val recipeRepository: RecipeRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _listState = MutableStateFlow(RecipesUiState(isLoading = true))
|
||||||
|
val listState: StateFlow<RecipesUiState> = _listState.asStateFlow()
|
||||||
|
|
||||||
|
private val _detailState = MutableStateFlow(RecipeDetailUiState())
|
||||||
|
val detailState: StateFlow<RecipeDetailUiState> = _detailState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
recipeRepository.getLocalRecipes().collect { recipes ->
|
||||||
|
_listState.update { it.copy(recipes = recipes, isLoading = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchRecipes()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fetchRecipes(filter: String = _listState.value.currentFilter, search: String? = null, page: Int = 1) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_listState.update { it.copy(isLoading = true, error = null, currentFilter = filter, searchQuery = search ?: "") }
|
||||||
|
when (val result = recipeRepository.fetchRecipes(filter, page, search)) {
|
||||||
|
is Result.Success -> _listState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
currentPage = result.data.pagination.page,
|
||||||
|
totalPages = result.data.pagination.totalPages
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is Result.Error -> _listState.update { it.copy(isLoading = false, error = result.toUserMessage()) }
|
||||||
|
is Result.NetworkError -> _listState.update { it.copy(isLoading = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadRecipeDetail(recipeId: String, scale: Int = 1) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_detailState.value = RecipeDetailUiState(isLoading = true, scaleFactor = scale)
|
||||||
|
when (val result = recipeRepository.getRecipeById(recipeId, scale)) {
|
||||||
|
is Result.Success -> _detailState.value = RecipeDetailUiState(recipe = result.data.recipe, scaleFactor = scale)
|
||||||
|
is Result.Error -> _detailState.value = RecipeDetailUiState(error = result.toUserMessage())
|
||||||
|
is Result.NetworkError -> _detailState.value = RecipeDetailUiState(error = "No internet connection.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setScale(recipeId: String, scale: Int) {
|
||||||
|
if (scale in 1..3) loadRecipeDetail(recipeId, scale)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package com.pantree.app.ui.screens.recipe
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.pantree.app.data.local.entity.RecipeEntity
|
||||||
|
import com.pantree.app.ui.components.EmptyState
|
||||||
|
import com.pantree.app.ui.components.ErrorState
|
||||||
|
import com.pantree.app.ui.components.LoadingState
|
||||||
|
import com.pantree.app.ui.theme.SuccessGreen
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun RecipesScreen(
|
||||||
|
onRecipeClick: (String) -> Unit,
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
viewModel: RecipeViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.listState.collectAsStateWithLifecycle()
|
||||||
|
var searchText by remember { mutableStateOf("") }
|
||||||
|
var showSearch by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
if (showSearch) {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = searchText,
|
||||||
|
onValueChange = {
|
||||||
|
searchText = it
|
||||||
|
viewModel.fetchRecipes(search = it.ifBlank { null })
|
||||||
|
},
|
||||||
|
placeholder = { Text("Search recipes...") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = Color.Transparent, unfocusedBorderColor = Color.Transparent)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { showSearch = false; searchText = ""; viewModel.fetchRecipes() }) {
|
||||||
|
Icon(Icons.Default.ArrowBack, "Close search", tint = MaterialTheme.colorScheme.onPrimary)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.primary)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Recipes") },
|
||||||
|
navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.Default.ArrowBack, "Back", tint = MaterialTheme.colorScheme.onPrimary) } },
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { showSearch = true }) {
|
||||||
|
Icon(Icons.Default.Search, "Search", tint = MaterialTheme.colorScheme.onPrimary)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
|
||||||
|
// Filter chips
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
listOf("all" to "All", "can_make" to "Can make", "can_partially_make" to "Partial").forEach { (value, label) ->
|
||||||
|
FilterChip(
|
||||||
|
selected = uiState.currentFilter == value,
|
||||||
|
onClick = { viewModel.fetchRecipes(filter = value) },
|
||||||
|
label = { Text(label) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when {
|
||||||
|
uiState.isLoading && uiState.recipes.isEmpty() -> LoadingState(message = "Finding recipes...")
|
||||||
|
uiState.error != null && uiState.recipes.isEmpty() -> ErrorState(
|
||||||
|
message = uiState.error!!,
|
||||||
|
onRetry = { viewModel.fetchRecipes() }
|
||||||
|
)
|
||||||
|
uiState.recipes.isEmpty() -> EmptyState(
|
||||||
|
emoji = "\uD83D\uDCDA",
|
||||||
|
title = when (uiState.currentFilter) {
|
||||||
|
"can_make" -> "Nothing to cook yet"
|
||||||
|
"can_partially_make" -> "No partial matches"
|
||||||
|
else -> "No recipes found"
|
||||||
|
},
|
||||||
|
subtitle = when (uiState.currentFilter) {
|
||||||
|
"can_make" -> "Add more ingredients to your pantry to unlock recipes."
|
||||||
|
"can_partially_make" -> "Try adding a few more pantry items."
|
||||||
|
else -> "Try a different search term."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else -> {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
items(uiState.recipes, key = { it.id }) { recipe ->
|
||||||
|
RecipeCard(recipe = recipe, onClick = { onRecipeClick(recipe.id) })
|
||||||
|
}
|
||||||
|
item { Spacer(Modifier.height(16.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RecipeCard(recipe: RecipeEntity, onClick: () -> Unit) {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth(), onClick = onClick) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(recipe.name, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f))
|
||||||
|
if (recipe.canMake) {
|
||||||
|
Surface(
|
||||||
|
color = SuccessGreen.copy(alpha = 0.15f),
|
||||||
|
shape = MaterialTheme.shapes.small
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"\u2705 Can make",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = SuccessGreen,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
Text(
|
||||||
|
"${recipe.servings} servings",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"${recipe.availableIngredientCount}/${recipe.ingredientCount} ingredients",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (recipe.canMake) SuccessGreen else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!recipe.canMake && recipe.ingredientCount > 0) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { (recipe.availabilityPercentage / 100f).toFloat() },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package com.pantree.app.ui.screens.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(
|
||||||
|
onSignOut: () -> Unit,
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
viewModel: SettingsViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(uiState.signedOut) { if (uiState.signedOut) onSignOut() }
|
||||||
|
LaunchedEffect(uiState.accountDeleted) { if (uiState.accountDeleted) onSignOut() }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Settings") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(Icons.Default.ArrowBack, "Back", tint = MaterialTheme.colorScheme.onPrimary)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
if (uiState.error != null) {
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
uiState.error!!,
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text("Account", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("Sign out") },
|
||||||
|
supportingContent = { Text("You can sign back in anytime.") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.signOut() },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
Text("Sign out")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text("Danger zone", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.error)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f))
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("Delete account", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
"Your account will be scheduled for deletion. You have 15 days to change your mind — just sign back in.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { showDeleteConfirm = true },
|
||||||
|
enabled = !uiState.isLoading,
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(18.dp))
|
||||||
|
} else {
|
||||||
|
Text("Delete my account")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDeleteConfirm) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showDeleteConfirm = false },
|
||||||
|
title = { Text("Delete account?") },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"This will schedule your account for permanent deletion in 15 days. " +
|
||||||
|
"All your pantry items, recipes, and shopping lists will be removed. " +
|
||||||
|
"Sign back in within 15 days to cancel."
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { viewModel.deleteAccount(); showDeleteConfirm = false }) {
|
||||||
|
Text("Yes, delete", color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showDeleteConfirm = false }) { Text("Keep my account") }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.pantree.app.ui.screens.settings
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.pantree.app.data.repository.AuthRepository
|
||||||
|
import com.pantree.app.util.Result
|
||||||
|
import com.pantree.app.util.toUserMessage
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class SettingsUiState(
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
val signedOut: Boolean = false,
|
||||||
|
val accountDeleted: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class SettingsViewModel @Inject constructor(
|
||||||
|
private val authRepository: AuthRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(SettingsUiState())
|
||||||
|
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
fun signOut() {
|
||||||
|
authRepository.logout()
|
||||||
|
_uiState.update { it.copy(signedOut = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteAccount() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true) }
|
||||||
|
when (val result = authRepository.deleteAccount()) {
|
||||||
|
is Result.Success -> {
|
||||||
|
authRepository.logout()
|
||||||
|
_uiState.update { it.copy(isLoading = false, accountDeleted = true) }
|
||||||
|
}
|
||||||
|
is Result.Error -> _uiState.update { it.copy(isLoading = false, error = result.toUserMessage()) }
|
||||||
|
is Result.NetworkError -> _uiState.update { it.copy(isLoading = false, error = "No internet connection.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearError() = _uiState.update { it.copy(error = null) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
package com.pantree.app.ui.screens.shopping
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.pantree.app.data.local.entity.ShoppingListItemEntity
|
||||||
|
import com.pantree.app.ui.components.EmptyState
|
||||||
|
import com.pantree.app.ui.components.ErrorState
|
||||||
|
import com.pantree.app.ui.components.LoadingState
|
||||||
|
|
||||||
|
val ALLOWED_UNITS = listOf(
|
||||||
|
"cups", "tbsp", "tsp", "oz", "fl_oz",
|
||||||
|
"g", "kg", "ml", "l",
|
||||||
|
"pieces", "slices", "cloves", "pinch",
|
||||||
|
"whole", "can", "package", "bunch"
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ShoppingListDetailScreen(
|
||||||
|
listId: String,
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
onNavigateToRecipes: () -> Unit,
|
||||||
|
viewModel: ShoppingListViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.detailState.collectAsStateWithLifecycle()
|
||||||
|
var showAddItemDialog by remember { mutableStateOf(false) }
|
||||||
|
var deletingItemId by remember { mutableStateOf<String?>(null) }
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
LaunchedEffect(listId) { viewModel.loadListDetail(listId) }
|
||||||
|
|
||||||
|
LaunchedEffect(uiState.actionSuccess) {
|
||||||
|
uiState.actionSuccess?.let { snackbarHostState.showSnackbar(it); viewModel.clearDetailMessages() }
|
||||||
|
}
|
||||||
|
LaunchedEffect(uiState.actionError) {
|
||||||
|
uiState.actionError?.let { snackbarHostState.showSnackbar(it); viewModel.clearDetailMessages() }
|
||||||
|
}
|
||||||
|
LaunchedEffect(uiState.addRecipesSuccess) {
|
||||||
|
uiState.addRecipesSuccess?.let { snackbarHostState.showSnackbar(it); viewModel.clearDetailMessages() }
|
||||||
|
}
|
||||||
|
|
||||||
|
val uncheckedItems = uiState.items.filter { !it.checkedOff }
|
||||||
|
val checkedItems = uiState.items.filter { it.checkedOff }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(uiState.listName.ifBlank { "Shopping List" }) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(Icons.Default.ArrowBack, "Back", tint = MaterialTheme.colorScheme.onPrimary)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = onNavigateToRecipes) {
|
||||||
|
Icon(Icons.Default.MenuBook, "Add from recipes", tint = MaterialTheme.colorScheme.onPrimary)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
FloatingActionButton(onClick = { showAddItemDialog = true }) {
|
||||||
|
Icon(Icons.Default.Add, "Add item")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||||
|
) { padding ->
|
||||||
|
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
|
||||||
|
when {
|
||||||
|
uiState.isLoading && uiState.items.isEmpty() -> LoadingState(message = "Loading list...")
|
||||||
|
uiState.error != null && uiState.items.isEmpty() -> ErrorState(
|
||||||
|
message = uiState.error!!,
|
||||||
|
onRetry = { viewModel.loadListDetail(listId) }
|
||||||
|
)
|
||||||
|
uiState.items.isEmpty() -> EmptyState(
|
||||||
|
emoji = "\uD83D\uDCCB",
|
||||||
|
title = "This list is empty",
|
||||||
|
subtitle = "Add items manually or pull in ingredients from a recipe.",
|
||||||
|
actionLabel = "Add an item",
|
||||||
|
onAction = { showAddItemDialog = true }
|
||||||
|
)
|
||||||
|
else -> {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
// Progress summary
|
||||||
|
item {
|
||||||
|
if (uiState.items.isNotEmpty()) {
|
||||||
|
val progress = checkedItems.size.toFloat() / uiState.items.size
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(
|
||||||
|
"${checkedItems.size} of ${uiState.items.size} checked",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { progress },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unchecked items
|
||||||
|
items(uncheckedItems, key = { it.id }) { item ->
|
||||||
|
ShoppingListItemRow(
|
||||||
|
item = item,
|
||||||
|
onToggle = { viewModel.toggleCheckOff(listId, item) },
|
||||||
|
onDelete = { deletingItemId = item.id }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checked items section
|
||||||
|
if (checkedItems.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
"In cart (${checkedItems.size})",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
items(checkedItems, key = { it.id }) { item ->
|
||||||
|
ShoppingListItemRow(
|
||||||
|
item = item,
|
||||||
|
onToggle = { viewModel.toggleCheckOff(listId, item) },
|
||||||
|
onDelete = { deletingItemId = item.id }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item { Spacer(Modifier.height(80.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showAddItemDialog) {
|
||||||
|
AddShoppingItemDialog(
|
||||||
|
onDismiss = { showAddItemDialog = false },
|
||||||
|
onConfirm = { name, qty, unit ->
|
||||||
|
viewModel.addItem(listId, name, qty, unit)
|
||||||
|
showAddItemDialog = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
deletingItemId?.let { itemId ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { deletingItemId = null },
|
||||||
|
title = { Text("Remove item?") },
|
||||||
|
text = { Text("Remove this item from the list?") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { viewModel.deleteItem(listId, itemId); deletingItemId = null }) {
|
||||||
|
Text("Remove", color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = { deletingItemId = null }) { Text("Cancel") } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ShoppingListItemRow(
|
||||||
|
item: ShoppingListItemEntity,
|
||||||
|
onToggle: () -> Unit,
|
||||||
|
onDelete: () -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = if (item.checkedOff)
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||||
|
else MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = item.checkedOff,
|
||||||
|
onCheckedChange = { onToggle() }
|
||||||
|
)
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
item.itemName,
|
||||||
|
style = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
textDecoration = if (item.checkedOff) TextDecoration.LineThrough else null
|
||||||
|
),
|
||||||
|
color = if (item.checkedOff)
|
||||||
|
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
|
||||||
|
else MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"${formatQty(item.quantity)} ${item.unit}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onDelete) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
contentDescription = "Remove",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun formatQty(qty: Double): String =
|
||||||
|
if (qty == qty.toLong().toDouble()) qty.toLong().toString()
|
||||||
|
else String.format("%.2f", qty).trimEnd('0').trimEnd('.')
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AddShoppingItemDialog(onDismiss: () -> Unit, onConfirm: (String, Double, String) -> Unit) {
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
var quantityText by remember { mutableStateOf("1") }
|
||||||
|
var selectedUnit by remember { mutableStateOf("pieces") }
|
||||||
|
var unitExpanded by remember { mutableStateOf(false) }
|
||||||
|
val quantity = quantityText.toDoubleOrNull()
|
||||||
|
val isValid = name.isNotBlank() && quantity != null && quantity > 0
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Add item") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
label = { Text("Item name") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = quantityText,
|
||||||
|
onValueChange = { quantityText = it },
|
||||||
|
label = { Text("Qty") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
|
singleLine = true,
|
||||||
|
isError = quantityText.isNotEmpty() && quantity == null,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = unitExpanded,
|
||||||
|
onExpandedChange = { unitExpanded = it },
|
||||||
|
modifier = Modifier.weight(1.5f)
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = selectedUnit,
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text("Unit") },
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = unitExpanded) },
|
||||||
|
modifier = Modifier.menuAnchor()
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = unitExpanded,
|
||||||
|
onDismissRequest = { unitExpanded = false }
|
||||||
|
) {
|
||||||
|
ALLOWED_UNITS.forEach { unit ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(unit) },
|
||||||
|
onClick = { selectedUnit = unit; unitExpanded = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { if (isValid) onConfirm(name, quantity!!, selectedUnit) },
|
||||||
|
enabled = isValid
|
||||||
|
) { Text("Add") }
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package com.pantree.app.ui.screens.shopping
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.pantree.app.data.local.entity.ShoppingListEntity
|
||||||
|
import com.pantree.app.data.local.entity.ShoppingListItemEntity
|
||||||
|
import com.pantree.app.data.repository.ShoppingListRepository
|
||||||
|
import com.pantree.app.util.Result
|
||||||
|
import com.pantree.app.util.toUserMessage
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class ShoppingListsUiState(
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val lists: List<ShoppingListEntity> = emptyList(),
|
||||||
|
val error: String? = null,
|
||||||
|
val actionError: String? = null,
|
||||||
|
val actionSuccess: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ShoppingListDetailUiState(
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val listName: String = "",
|
||||||
|
val items: List<ShoppingListItemEntity> = emptyList(),
|
||||||
|
val error: String? = null,
|
||||||
|
val actionError: String? = null,
|
||||||
|
val actionSuccess: String? = null,
|
||||||
|
val addRecipesSuccess: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class ShoppingListViewModel @Inject constructor(
|
||||||
|
private val shoppingListRepository: ShoppingListRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _listsState = MutableStateFlow(ShoppingListsUiState(isLoading = true))
|
||||||
|
val listsState: StateFlow<ShoppingListsUiState> = _listsState.asStateFlow()
|
||||||
|
|
||||||
|
private val _detailState = MutableStateFlow(ShoppingListDetailUiState())
|
||||||
|
val detailState: StateFlow<ShoppingListDetailUiState> = _detailState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
shoppingListRepository.getLocalLists().collect { lists ->
|
||||||
|
_listsState.update { it.copy(lists = lists, isLoading = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchLists()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fetchLists() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_listsState.update { it.copy(isLoading = true, error = null) }
|
||||||
|
when (val result = shoppingListRepository.fetchLists()) {
|
||||||
|
is Result.Success -> _listsState.update { it.copy(isLoading = false) }
|
||||||
|
is Result.Error -> _listsState.update { it.copy(isLoading = false, error = result.toUserMessage()) }
|
||||||
|
is Result.NetworkError -> _listsState.update { it.copy(isLoading = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createList(name: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = shoppingListRepository.createList(name.trim())) {
|
||||||
|
is Result.Success -> _listsState.update { it.copy(actionSuccess = "\"${result.data.shoppingList.listName}\" created.") }
|
||||||
|
is Result.Error -> _listsState.update { it.copy(actionError = result.toUserMessage()) }
|
||||||
|
is Result.NetworkError -> _listsState.update { it.copy(actionError = "No internet connection.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteList(listId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = shoppingListRepository.deleteList(listId)) {
|
||||||
|
is Result.Success -> _listsState.update { it.copy(actionSuccess = "List deleted.") }
|
||||||
|
is Result.Error -> _listsState.update { it.copy(actionError = result.toUserMessage()) }
|
||||||
|
is Result.NetworkError -> _listsState.update { it.copy(actionError = "No internet connection.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadListDetail(listId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_detailState.update { it.copy(isLoading = true, error = null) }
|
||||||
|
// Observe local items immediately
|
||||||
|
launch {
|
||||||
|
shoppingListRepository.getLocalItems(listId).collect { items ->
|
||||||
|
_detailState.update { it.copy(items = items, isLoading = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when (val result = shoppingListRepository.fetchListById(listId)) {
|
||||||
|
is Result.Success -> _detailState.update {
|
||||||
|
it.copy(isLoading = false, listName = result.data.shoppingList.listName)
|
||||||
|
}
|
||||||
|
is Result.Error -> _detailState.update { it.copy(isLoading = false, error = result.toUserMessage()) }
|
||||||
|
is Result.NetworkError -> _detailState.update { it.copy(isLoading = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addItem(listId: String, name: String, quantity: Double, unit: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = shoppingListRepository.addItem(listId, name.trim(), quantity, unit)) {
|
||||||
|
is Result.Success -> {
|
||||||
|
val msg = if (result.data.merged == true)
|
||||||
|
"Merged with existing ${result.data.item.itemName}."
|
||||||
|
else "${result.data.item.itemName} added."
|
||||||
|
_detailState.update { it.copy(actionSuccess = msg, actionError = null) }
|
||||||
|
}
|
||||||
|
is Result.Error -> _detailState.update { it.copy(actionError = result.toUserMessage()) }
|
||||||
|
is Result.NetworkError -> _detailState.update { it.copy(actionError = "No internet connection.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addRecipesToList(listId: String, recipeIds: List<String>, scaleFactor: Int) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = shoppingListRepository.addRecipesToList(listId, recipeIds, scaleFactor)) {
|
||||||
|
is Result.Success -> _detailState.update {
|
||||||
|
it.copy(addRecipesSuccess = "Added ${result.data.recipesAdded} recipe(s). ${result.data.itemsMerged} items merged, ${result.data.itemsCreated} new.")
|
||||||
|
}
|
||||||
|
is Result.Error -> _detailState.update { it.copy(actionError = result.toUserMessage()) }
|
||||||
|
is Result.NetworkError -> _detailState.update { it.copy(actionError = "No internet connection.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleCheckOff(listId: String, item: ShoppingListItemEntity) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
shoppingListRepository.updateItem(listId, item.id, checkedOff = !item.checkedOff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteItem(listId: String, itemId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = shoppingListRepository.deleteItem(listId, itemId)) {
|
||||||
|
is Result.Success -> _detailState.update { it.copy(actionSuccess = "Item removed.") }
|
||||||
|
is Result.Error -> _detailState.update { it.copy(actionError = result.toUserMessage()) }
|
||||||
|
is Result.NetworkError -> _detailState.update { it.copy(actionError = "No internet connection.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearListsMessages() = _listsState.update { it.copy(actionError = null, actionSuccess = null) }
|
||||||
|
fun clearDetailMessages() = _detailState.update { it.copy(actionError = null, actionSuccess = null, addRecipesSuccess = null) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package com.pantree.app.ui.screens.shopping
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.pantree.app.data.local.entity.ShoppingListEntity
|
||||||
|
import com.pantree.app.ui.components.EmptyState
|
||||||
|
import com.pantree.app.ui.components.ErrorState
|
||||||
|
import com.pantree.app.ui.components.LoadingState
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ShoppingListsScreen(
|
||||||
|
onListClick: (String) -> Unit,
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
viewModel: ShoppingListViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.listsState.collectAsStateWithLifecycle()
|
||||||
|
var showCreateDialog by remember { mutableStateOf(false) }
|
||||||
|
var deletingList by remember { mutableStateOf<ShoppingListEntity?>(null) }
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
LaunchedEffect(uiState.actionSuccess) {
|
||||||
|
uiState.actionSuccess?.let { snackbarHostState.showSnackbar(it); viewModel.clearListsMessages() }
|
||||||
|
}
|
||||||
|
LaunchedEffect(uiState.actionError) {
|
||||||
|
uiState.actionError?.let { snackbarHostState.showSnackbar(it); viewModel.clearListsMessages() }
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Shopping Lists") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(Icons.Default.ArrowBack, "Back", tint = MaterialTheme.colorScheme.onPrimary)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
FloatingActionButton(onClick = { showCreateDialog = true }) {
|
||||||
|
Icon(Icons.Default.Add, "Create list")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||||
|
) { padding ->
|
||||||
|
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
|
||||||
|
when {
|
||||||
|
uiState.isLoading && uiState.lists.isEmpty() -> LoadingState(message = "Loading your lists...")
|
||||||
|
uiState.error != null && uiState.lists.isEmpty() -> ErrorState(
|
||||||
|
message = uiState.error!!,
|
||||||
|
onRetry = { viewModel.fetchLists() }
|
||||||
|
)
|
||||||
|
uiState.lists.isEmpty() -> EmptyState(
|
||||||
|
emoji = "\uD83D\uDED2",
|
||||||
|
title = "No shopping lists yet",
|
||||||
|
subtitle = "Create a list to start planning your next grocery run.",
|
||||||
|
actionLabel = "Create a list",
|
||||||
|
onAction = { showCreateDialog = true }
|
||||||
|
)
|
||||||
|
else -> {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
items(uiState.lists, key = { it.id }) { list ->
|
||||||
|
ShoppingListCard(
|
||||||
|
list = list,
|
||||||
|
onClick = { onListClick(list.id) },
|
||||||
|
onDelete = { deletingList = list }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item { Spacer(Modifier.height(80.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showCreateDialog) {
|
||||||
|
CreateListDialog(
|
||||||
|
onDismiss = { showCreateDialog = false },
|
||||||
|
onConfirm = { name -> viewModel.createList(name); showCreateDialog = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
deletingList?.let { list ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { deletingList = null },
|
||||||
|
title = { Text("Delete list?") },
|
||||||
|
text = { Text("\"${list.listName}\" and all its items will be permanently deleted.") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { viewModel.deleteList(list.id); deletingList = null }) {
|
||||||
|
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = { deletingList = null }) { Text("Cancel") } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ShoppingListCard(
|
||||||
|
list: ShoppingListEntity,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onDelete: () -> Unit
|
||||||
|
) {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth(), onClick = onClick) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(list.listName, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(
|
||||||
|
"${list.itemCount} item${if (list.itemCount != 1) "s" else ""}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
if (list.checkedCount > 0) {
|
||||||
|
Text(
|
||||||
|
"${list.checkedCount} checked",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Icon(Icons.Default.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f))
|
||||||
|
IconButton(onClick = onDelete) {
|
||||||
|
Icon(Icons.Default.Delete, "Delete", tint = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CreateListDialog(onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("New shopping list") },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
label = { Text("List name") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { if (name.isNotBlank()) onConfirm(name) }, enabled = name.isNotBlank()) {
|
||||||
|
Text("Create")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
|
||||||
|
)
|
||||||
|
}
|
||||||
24
android/app/src/main/java/com/pantree/app/ui/theme/Color.kt
Normal file
24
android/app/src/main/java/com/pantree/app/ui/theme/Color.kt
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package com.pantree.app.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
// Brand palette
|
||||||
|
val PantreeGreen = Color(0xFF2D6A4F)
|
||||||
|
val PantreeGreenLight = Color(0xFF52B788)
|
||||||
|
val PantreeGreenDark = Color(0xFF1B4332)
|
||||||
|
val PantreeCream = Color(0xFFF8F4EF)
|
||||||
|
val PantreeOrange = Color(0xFFE76F51)
|
||||||
|
val PantreeOrangeLight = Color(0xFFF4A261)
|
||||||
|
|
||||||
|
// Semantic
|
||||||
|
val SuccessGreen = Color(0xFF40916C)
|
||||||
|
val ErrorRed = Color(0xFFD62828)
|
||||||
|
val WarningAmber = Color(0xFFF4A261)
|
||||||
|
val NeutralGray = Color(0xFF6B7280)
|
||||||
|
val SurfaceWhite = Color(0xFFFFFFFF)
|
||||||
|
val BackgroundCream = Color(0xFFF8F4EF)
|
||||||
|
|
||||||
|
// Dark theme
|
||||||
|
val PantreeGreenDarkTheme = Color(0xFF52B788)
|
||||||
|
val SurfaceDark = Color(0xFF1C1C1E)
|
||||||
|
val BackgroundDark = Color(0xFF121212)
|
||||||
65
android/app/src/main/java/com/pantree/app/ui/theme/Theme.kt
Normal file
65
android/app/src/main/java/com/pantree/app/ui/theme/Theme.kt
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package com.pantree.app.ui.theme
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
|
||||||
|
private val LightColorScheme = lightColorScheme(
|
||||||
|
primary = PantreeGreen,
|
||||||
|
onPrimary = SurfaceWhite,
|
||||||
|
primaryContainer = PantreeGreenLight,
|
||||||
|
onPrimaryContainer = PantreeGreenDark,
|
||||||
|
secondary = PantreeOrange,
|
||||||
|
onSecondary = SurfaceWhite,
|
||||||
|
secondaryContainer = PantreeOrangeLight,
|
||||||
|
background = BackgroundCream,
|
||||||
|
onBackground = PantreeGreenDark,
|
||||||
|
surface = SurfaceWhite,
|
||||||
|
onSurface = PantreeGreenDark,
|
||||||
|
error = ErrorRed,
|
||||||
|
onError = SurfaceWhite,
|
||||||
|
outline = NeutralGray
|
||||||
|
)
|
||||||
|
|
||||||
|
private val DarkColorScheme = darkColorScheme(
|
||||||
|
primary = PantreeGreenDarkTheme,
|
||||||
|
onPrimary = PantreeGreenDark,
|
||||||
|
primaryContainer = PantreeGreenDark,
|
||||||
|
onPrimaryContainer = PantreeGreenLight,
|
||||||
|
secondary = PantreeOrangeLight,
|
||||||
|
onSecondary = SurfaceDark,
|
||||||
|
background = BackgroundDark,
|
||||||
|
onBackground = PantreeCream,
|
||||||
|
surface = SurfaceDark,
|
||||||
|
onSurface = PantreeCream,
|
||||||
|
error = ErrorRed,
|
||||||
|
onError = SurfaceWhite
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PantreeTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
|
||||||
|
val view = LocalView.current
|
||||||
|
if (!view.isInEditMode) {
|
||||||
|
SideEffect {
|
||||||
|
val window = (view.context as Activity).window
|
||||||
|
window.statusBarColor = colorScheme.primary.toArgb()
|
||||||
|
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = colorScheme,
|
||||||
|
typography = PantreeTypography,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
54
android/app/src/main/java/com/pantree/app/ui/theme/Type.kt
Normal file
54
android/app/src/main/java/com/pantree/app/ui/theme/Type.kt
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package com.pantree.app.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
val PantreeTypography = Typography(
|
||||||
|
headlineLarge = TextStyle(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 32.sp,
|
||||||
|
lineHeight = 40.sp
|
||||||
|
),
|
||||||
|
headlineMedium = TextStyle(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 24.sp,
|
||||||
|
lineHeight = 32.sp
|
||||||
|
),
|
||||||
|
headlineSmall = TextStyle(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
lineHeight = 28.sp
|
||||||
|
),
|
||||||
|
titleLarge = TextStyle(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
lineHeight = 24.sp
|
||||||
|
),
|
||||||
|
titleMedium = TextStyle(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 22.sp
|
||||||
|
),
|
||||||
|
bodyLarge = TextStyle(
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 24.sp
|
||||||
|
),
|
||||||
|
bodyMedium = TextStyle(
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp
|
||||||
|
),
|
||||||
|
bodySmall = TextStyle(
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
lineHeight = 16.sp
|
||||||
|
),
|
||||||
|
labelLarge = TextStyle(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp
|
||||||
|
)
|
||||||
|
)
|
||||||
58
android/app/src/main/java/com/pantree/app/util/Result.kt
Normal file
58
android/app/src/main/java/com/pantree/app/util/Result.kt
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package com.pantree.app.util
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.pantree.app.data.model.ApiError
|
||||||
|
import retrofit2.Response
|
||||||
|
|
||||||
|
sealed class Result<out T> {
|
||||||
|
data class Success<T>(val data: T) : Result<T>()
|
||||||
|
data class Error(val code: String, val message: String, val httpStatus: Int = 0) : Result<Nothing>()
|
||||||
|
object NetworkError : Result<Nothing>()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun <T> safeApiCall(gson: Gson, call: suspend () -> Response<T>): Result<T> {
|
||||||
|
return try {
|
||||||
|
val response = call()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val body = response.body()
|
||||||
|
if (body != null) {
|
||||||
|
Result.Success(body)
|
||||||
|
} else if (response.code() == 204) {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
Result.Success(Unit as T)
|
||||||
|
} else {
|
||||||
|
Result.Error("EMPTY_BODY", "Empty response body", response.code())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string()
|
||||||
|
val apiError = try {
|
||||||
|
gson.fromJson(errorBody, ApiError::class.java)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
Result.Error(
|
||||||
|
code = apiError?.code ?: "UNKNOWN_ERROR",
|
||||||
|
message = apiError?.error ?: "Something went wrong. Please try again.",
|
||||||
|
httpStatus = response.code()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: java.io.IOException) {
|
||||||
|
Result.NetworkError
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.Error("UNKNOWN_ERROR", e.message ?: "An unexpected error occurred.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Result.Error.toUserMessage(): String = when (code) {
|
||||||
|
"VALIDATION_ERROR" -> message
|
||||||
|
"UNAUTHORIZED" -> "Your session has expired. Please sign in again."
|
||||||
|
"FORBIDDEN" -> message
|
||||||
|
"NOT_FOUND" -> "That item couldn't be found."
|
||||||
|
"CONFLICT" -> message
|
||||||
|
"DUPLICATE_ITEM" -> message
|
||||||
|
"ACCOUNT_PENDING_DELETION" -> message
|
||||||
|
"GONE" -> "This account no longer exists."
|
||||||
|
"INVALID_TOKEN" -> "That link has expired. Please request a new one."
|
||||||
|
"INVALID_GOOGLE_TOKEN" -> "Google sign-in failed. Please try again."
|
||||||
|
else -> "Something went wrong. Please try again."
|
||||||
|
}
|
||||||
4
android/app/src/main/res/values/strings.xml
Normal file
4
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Pantree</string>
|
||||||
|
</resources>
|
||||||
4
android/app/src/main/res/values/themes.xml
Normal file
4
android/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.Pantree" parent="android:Theme.Material.Light.NoActionBar" />
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.pantree.app.ui.screens.recipe
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class RecipeDetailScreenTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `formatQuantity whole number strips decimal`() {
|
||||||
|
assertEquals("2", formatQuantity(2.0))
|
||||||
|
assertEquals("10", formatQuantity(10.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `formatQuantity decimal trims trailing zeros`() {
|
||||||
|
assertEquals("2.5", formatQuantity(2.5))
|
||||||
|
assertEquals("0.25", formatQuantity(0.25))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `formatQuantity handles repeating decimal`() {
|
||||||
|
val result = formatQuantity(0.6666666666)
|
||||||
|
assertTrue(result.startsWith("0.66"))
|
||||||
|
}
|
||||||
|
}
|
||||||
31
android/app/src/test/java/com/pantree/app/util/ResultTest.kt
Normal file
31
android/app/src/test/java/com/pantree/app/util/ResultTest.kt
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package com.pantree.app.util
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ResultTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toUserMessage returns human message for UNAUTHORIZED`() {
|
||||||
|
val error = Result.Error(code = "UNAUTHORIZED", message = "raw", httpStatus = 401)
|
||||||
|
assertEquals("Your session has expired. Please sign in again.", error.toUserMessage())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toUserMessage returns human message for NOT_FOUND`() {
|
||||||
|
val error = Result.Error(code = "NOT_FOUND", message = "raw", httpStatus = 404)
|
||||||
|
assertEquals("That item couldn't be found.", error.toUserMessage())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toUserMessage passes through VALIDATION_ERROR message`() {
|
||||||
|
val error = Result.Error(code = "VALIDATION_ERROR", message = "Email is required", httpStatus = 400)
|
||||||
|
assertEquals("Email is required", error.toUserMessage())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toUserMessage returns generic for unknown code`() {
|
||||||
|
val error = Result.Error(code = "SOME_WEIRD_CODE", message = "raw", httpStatus = 500)
|
||||||
|
assertEquals("Something went wrong. Please try again.", error.toUserMessage())
|
||||||
|
}
|
||||||
|
}
|
||||||
8
android/build.gradle
Normal file
8
android/build.gradle
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// Top-level build file
|
||||||
|
plugins {
|
||||||
|
id 'com.android.application' version '8.3.0' apply false
|
||||||
|
id 'com.android.library' version '8.3.0' apply false
|
||||||
|
id 'org.jetbrains.kotlin.android' version '1.9.23' apply false
|
||||||
|
id 'com.google.dagger.hilt.android' version '2.51.1' apply false
|
||||||
|
id 'com.google.devtools.ksp' version '1.9.23-1.0.20' apply false
|
||||||
|
}
|
||||||
16
android/settings.gradle
Normal file
16
android/settings.gradle
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rootProject.name = "Pantree"
|
||||||
|
include ':app'
|
||||||
24
jest.config.js
Normal file
24
jest.config.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/** @type {import('jest').Config} */
|
||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
roots: ['<rootDir>/src'],
|
||||||
|
testMatch: ['**/*.test.ts'],
|
||||||
|
collectCoverageFrom: [
|
||||||
|
'src/**/*.ts',
|
||||||
|
'!src/**/*.test.ts',
|
||||||
|
'!src/db/knexfile.ts',
|
||||||
|
'!src/db/migrations/**',
|
||||||
|
'!src/db/seeds/**',
|
||||||
|
'!src/server.ts'
|
||||||
|
],
|
||||||
|
coverageThreshold: {
|
||||||
|
global: {
|
||||||
|
branches: 70,
|
||||||
|
functions: 80,
|
||||||
|
lines: 80,
|
||||||
|
statements: 80
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setupFiles: ['<rootDir>/src/test/setup.ts']
|
||||||
|
};
|
||||||
51
package.json
51
package.json
@@ -1,36 +1,49 @@
|
|||||||
{
|
{
|
||||||
"name": "pantree-backend",
|
"name": "pantree-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Pantree backend API server",
|
"description": "Pantree API Server",
|
||||||
"main": "src/main/index.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/main/index.js",
|
"build": "tsc",
|
||||||
"dev": "nodemon src/main/index.js",
|
"start": "node dist/server.js",
|
||||||
|
"dev": "ts-node-dev --respawn --transpile-only src/server.ts",
|
||||||
"test": "jest --runInBand --forceExit",
|
"test": "jest --runInBand --forceExit",
|
||||||
"test:coverage": "jest --runInBand --forceExit --coverage"
|
"test:coverage": "jest --runInBand --forceExit --coverage",
|
||||||
|
"migrate": "knex migrate:latest --knexfile src/db/knexfile.ts",
|
||||||
|
"migrate:rollback": "knex migrate:rollback --knexfile src/db/knexfile.ts",
|
||||||
|
"seed": "knex seed:run --knexfile src/db/knexfile.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@sendgrid/mail": "^8.1.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.18.2",
|
"express": "^4.19.2",
|
||||||
|
"google-auth-library": "^9.10.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"knex": "^3.1.0",
|
"knex": "^3.1.0",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
"nodemailer": "^6.9.7",
|
"pg": "^8.11.5",
|
||||||
"pg": "^8.11.3",
|
"pino": "^9.1.0",
|
||||||
"uuid": "^9.0.0",
|
"pino-http": "^10.1.0",
|
||||||
"zod": "^3.22.4"
|
"uuid": "^9.0.1",
|
||||||
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jest": "^29.5.12",
|
||||||
|
"@types/jsonwebtoken": "^9.0.6",
|
||||||
|
"@types/node": "^20.12.12",
|
||||||
|
"@types/node-cron": "^3.0.11",
|
||||||
|
"@types/supertest": "^6.0.2",
|
||||||
|
"@types/uuid": "^9.0.8",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"supertest": "^6.3.3"
|
"supertest": "^7.0.0",
|
||||||
},
|
"ts-jest": "^29.1.4",
|
||||||
"jest": {
|
"ts-node": "^10.9.2",
|
||||||
"testEnvironment": "node",
|
"ts-node-dev": "^2.0.0",
|
||||||
"testMatch": [
|
"typescript": "^5.4.5"
|
||||||
"**/tests/**/*.test.js"
|
|
||||||
],
|
|
||||||
"setupFilesAfterFramework": []
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
42
src/app.ts
Normal file
42
src/app.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import { httpLogger } from './utils/logger';
|
||||||
|
import { errorHandler } from './middleware/errorHandler';
|
||||||
|
import authRoutes from './routes/auth';
|
||||||
|
import pantryRoutes from './routes/pantry';
|
||||||
|
import recipeRoutes from './routes/recipes';
|
||||||
|
import shoppingListRoutes from './routes/shoppingLists';
|
||||||
|
import syncRoutes from './routes/sync';
|
||||||
|
|
||||||
|
export function createApp() {
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(httpLogger);
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/health', (_req, res) => {
|
||||||
|
res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
app.use('/v1/auth', authRoutes);
|
||||||
|
app.use('/v1/pantry', pantryRoutes);
|
||||||
|
app.use('/v1/recipes', recipeRoutes);
|
||||||
|
app.use('/v1/shopping-lists', shoppingListRoutes);
|
||||||
|
app.use('/v1/sync', syncRoutes);
|
||||||
|
|
||||||
|
// 404 handler
|
||||||
|
app.use((_req, res) => {
|
||||||
|
res.status(404).json({
|
||||||
|
error: 'Route not found.',
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
18
src/config/constants.ts
Normal file
18
src/config/constants.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export const ALLOWED_UNITS = [
|
||||||
|
'cups', 'tbsp', 'tsp', 'oz', 'fl_oz',
|
||||||
|
'g', 'kg', 'ml', 'l',
|
||||||
|
'pieces', 'slices', 'cloves', 'pinch',
|
||||||
|
'whole', 'can', 'package', 'bunch'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type AllowedUnit = typeof ALLOWED_UNITS[number];
|
||||||
|
|
||||||
|
export const BCRYPT_ROUNDS = 12;
|
||||||
|
export const JWT_EXPIRES_IN = '24h';
|
||||||
|
export const PASSWORD_RESET_EXPIRES_HOURS = 1;
|
||||||
|
export const ACCOUNT_DELETION_DAYS = 15;
|
||||||
|
export const TOMBSTONE_RETENTION_DAYS = 30;
|
||||||
|
export const MAX_RECIPE_SCALE = 3;
|
||||||
|
export const MIN_RECIPE_SCALE = 1;
|
||||||
|
export const DEFAULT_PAGE_LIMIT = 20;
|
||||||
|
export const MAX_PAGE_LIMIT = 50;
|
||||||
26
src/config/env.ts
Normal file
26
src/config/env.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import dotenv from 'dotenv';
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
function requireEnv(key: string): string {
|
||||||
|
const value = process.env[key];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`Missing required environment variable: ${key}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
nodeEnv: process.env.NODE_ENV ?? 'development',
|
||||||
|
port: parseInt(process.env.PORT ?? '3000', 10),
|
||||||
|
databaseUrl: process.env.DATABASE_URL ?? 'postgresql://postgres:postgres@localhost:5432/pantree_test',
|
||||||
|
jwtSecret: process.env.JWT_SECRET ?? 'test_secret_do_not_use_in_production_ever',
|
||||||
|
jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '24h',
|
||||||
|
googleClientId: process.env.GOOGLE_CLIENT_ID ?? '',
|
||||||
|
sendgridApiKey: process.env.SENDGRID_API_KEY ?? '',
|
||||||
|
sendgridFromEmail: process.env.SENDGRID_FROM_EMAIL ?? 'noreply@pantree.app',
|
||||||
|
frontendUrl: process.env.FRONTEND_URL ?? 'http://localhost:3000',
|
||||||
|
passwordResetUrl: process.env.PASSWORD_RESET_URL ?? 'http://localhost:3000/reset-password',
|
||||||
|
isTest: process.env.NODE_ENV === 'test',
|
||||||
|
isDev: process.env.NODE_ENV === 'development',
|
||||||
|
isProd: process.env.NODE_ENV === 'production',
|
||||||
|
};
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
require('dotenv').config();
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
port: parseInt(process.env.PORT || '3000', 10),
|
|
||||||
nodeEnv: process.env.NODE_ENV || 'development',
|
|
||||||
|
|
||||||
db: {
|
|
||||||
host: process.env.DB_HOST || 'localhost',
|
|
||||||
port: parseInt(process.env.DB_PORT || '5432', 10),
|
|
||||||
database: process.env.DB_NAME || 'pantree',
|
|
||||||
user: process.env.DB_USER || 'postgres',
|
|
||||||
password: process.env.DB_PASSWORD || '',
|
|
||||||
},
|
|
||||||
|
|
||||||
jwt: {
|
|
||||||
secret: process.env.JWT_SECRET || 'dev-secret-change-in-production',
|
|
||||||
expiresIn: process.env.JWT_EXPIRES_IN || '24h',
|
|
||||||
},
|
|
||||||
|
|
||||||
google: {
|
|
||||||
clientId: process.env.GOOGLE_CLIENT_ID || '',
|
|
||||||
},
|
|
||||||
|
|
||||||
email: {
|
|
||||||
host: process.env.EMAIL_HOST || 'smtp.ethereal.email',
|
|
||||||
port: parseInt(process.env.EMAIL_PORT || '587', 10),
|
|
||||||
user: process.env.EMAIL_USER || '',
|
|
||||||
pass: process.env.EMAIL_PASS || '',
|
|
||||||
from: process.env.EMAIL_FROM || 'noreply@pantree.app',
|
|
||||||
},
|
|
||||||
|
|
||||||
appBaseUrl: process.env.APP_BASE_URL || 'https://pantree.app',
|
|
||||||
|
|
||||||
accountDeletionDays: parseInt(process.env.ACCOUNT_DELETION_DAYS || '15', 10),
|
|
||||||
resetTokenExpiryHours: parseInt(process.env.RESET_TOKEN_EXPIRY_HOURS || '1', 10),
|
|
||||||
};
|
|
||||||
6
src/db/connection.ts
Normal file
6
src/db/connection.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import knex from 'knex';
|
||||||
|
import knexConfig from './knexfile';
|
||||||
|
|
||||||
|
const db = knex(knexConfig);
|
||||||
|
|
||||||
|
export default db;
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const knex = require('knex');
|
|
||||||
const config = require('../config');
|
|
||||||
|
|
||||||
let instance = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the singleton Knex instance.
|
|
||||||
* In tests, call setDb() to inject a test database.
|
|
||||||
*/
|
|
||||||
function getDb() {
|
|
||||||
if (!instance) {
|
|
||||||
instance = knex({
|
|
||||||
client: 'pg',
|
|
||||||
connection: config.db,
|
|
||||||
pool: { min: 2, max: 10 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replaces the singleton — used in tests to inject an in-memory or
|
|
||||||
* test-scoped database without touching the real connection.
|
|
||||||
*/
|
|
||||||
function setDb(db) {
|
|
||||||
instance = db;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { getDb, setDb };
|
|
||||||
22
src/db/knexfile.ts
Normal file
22
src/db/knexfile.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Knex } from 'knex';
|
||||||
|
import { config } from '../config/env';
|
||||||
|
|
||||||
|
const knexConfig: Knex.Config = {
|
||||||
|
client: 'pg',
|
||||||
|
connection: config.databaseUrl,
|
||||||
|
migrations: {
|
||||||
|
directory: './migrations',
|
||||||
|
extension: 'ts',
|
||||||
|
},
|
||||||
|
seeds: {
|
||||||
|
directory: './seeds',
|
||||||
|
extension: 'ts',
|
||||||
|
},
|
||||||
|
pool: {
|
||||||
|
min: 2,
|
||||||
|
max: 10,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default knexConfig;
|
||||||
|
module.exports = knexConfig;
|
||||||
27
src/db/migrations/001_create_users.ts
Normal file
27
src/db/migrations/001_create_users.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { Knex } from 'knex';
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
await knex.raw('CREATE EXTENSION IF NOT EXISTS "pgcrypto"');
|
||||||
|
|
||||||
|
await knex.schema.createTable('users', (table) => {
|
||||||
|
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
|
||||||
|
table.string('email', 255).notNullable();
|
||||||
|
table.string('password_hash', 255).nullable();
|
||||||
|
table.string('name', 255).notNullable();
|
||||||
|
table.text('profile_picture_url').nullable();
|
||||||
|
table.string('google_id', 255).nullable();
|
||||||
|
table.boolean('email_verified').defaultTo(false);
|
||||||
|
table.timestamp('deleted_at', { useTz: true }).nullable();
|
||||||
|
table.timestamp('deletion_scheduled_at', { useTz: true }).nullable();
|
||||||
|
table.timestamp('created_at', { useTz: true }).defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updated_at', { useTz: true }).defaultTo(knex.fn.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex.raw(`CREATE UNIQUE INDEX idx_users_email ON users (LOWER(email))`);
|
||||||
|
await knex.raw(`CREATE UNIQUE INDEX idx_users_google_id ON users (google_id) WHERE google_id IS NOT NULL`);
|
||||||
|
await knex.raw(`CREATE INDEX idx_users_deletion_scheduled ON users (deletion_scheduled_at) WHERE deletion_scheduled_at IS NOT NULL`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
await knex.schema.dropTableIfExists('users');
|
||||||
|
}
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
-- Migration: 001_initial_schema.sql
|
|
||||||
-- Full schema for Pantree Phase 1 MVP
|
|
||||||
-- Run once against a fresh PostgreSQL 15+ database.
|
|
||||||
|
|
||||||
-- ─────────────────────────────────────────────
|
|
||||||
-- Extensions
|
|
||||||
-- ─────────────────────────────────────────────
|
|
||||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
||||||
|
|
||||||
-- ─────────────────────────────────────────────
|
|
||||||
-- USERS
|
|
||||||
-- ─────────────────────────────────────────────
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
email VARCHAR(255) NOT NULL,
|
|
||||||
password_hash VARCHAR(255) NULL,
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
profile_picture_url TEXT NULL,
|
|
||||||
auth_provider VARCHAR(20) NOT NULL DEFAULT 'email',
|
|
||||||
google_id VARCHAR(255) NULL,
|
|
||||||
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
deleted_at TIMESTAMPTZ NULL,
|
|
||||||
deletion_scheduled_at TIMESTAMPTZ NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email
|
|
||||||
ON users (LOWER(email));
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_google_id
|
|
||||||
ON users (google_id)
|
|
||||||
WHERE google_id IS NOT NULL;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_deletion_scheduled
|
|
||||||
ON users (deletion_scheduled_at)
|
|
||||||
WHERE deletion_scheduled_at IS NOT NULL;
|
|
||||||
|
|
||||||
-- ─────────────────────────────────────────────
|
|
||||||
-- PANTRY ITEMS
|
|
||||||
-- ─────────────────────────────────────────────
|
|
||||||
CREATE TABLE IF NOT EXISTS pantry_items (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
item_name VARCHAR(200) NOT NULL,
|
|
||||||
item_name_lower VARCHAR(200) GENERATED ALWAYS AS (LOWER(item_name)) STORED,
|
|
||||||
quantity INTEGER NOT NULL CHECK (quantity > 0),
|
|
||||||
last_modified TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_pantry_user_item
|
|
||||||
ON pantry_items (user_id, item_name_lower);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_pantry_user_id
|
|
||||||
ON pantry_items (user_id);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_pantry_last_modified
|
|
||||||
ON pantry_items (user_id, last_modified);
|
|
||||||
|
|
||||||
-- ─────────────────────────────────────────────
|
|
||||||
-- RECIPES
|
|
||||||
-- ─────────────────────────────────────────────
|
|
||||||
CREATE TABLE IF NOT EXISTS recipes (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
name VARCHAR(300) NOT NULL,
|
|
||||||
instructions TEXT NOT NULL,
|
|
||||||
servings INTEGER NOT NULL CHECK (servings > 0),
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_recipes_name
|
|
||||||
ON recipes (LOWER(name));
|
|
||||||
|
|
||||||
-- ─────────────────────────────────────────────
|
|
||||||
-- RECIPE INGREDIENTS
|
|
||||||
-- ─────────────────────────────────────────────
|
|
||||||
CREATE TABLE IF NOT EXISTS recipe_ingredients (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
|
||||||
item_name VARCHAR(200) NOT NULL,
|
|
||||||
item_name_lower VARCHAR(200) GENERATED ALWAYS AS (LOWER(item_name)) STORED,
|
|
||||||
quantity DECIMAL(10,4) NOT NULL CHECK (quantity > 0),
|
|
||||||
unit VARCHAR(50) NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_recipe_ingredients_recipe
|
|
||||||
ON recipe_ingredients (recipe_id);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_recipe_ingredients_name
|
|
||||||
ON recipe_ingredients (item_name_lower);
|
|
||||||
|
|
||||||
-- ─────────────────────────────────────────────
|
|
||||||
-- SHOPPING LISTS
|
|
||||||
-- ─────────────────────────────────────────────
|
|
||||||
CREATE TABLE IF NOT EXISTS shopping_lists (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
list_name VARCHAR(200) NOT NULL,
|
|
||||||
last_modified TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_shopping_lists_user
|
|
||||||
ON shopping_lists (user_id);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_shopping_lists_last_modified
|
|
||||||
ON shopping_lists (user_id, last_modified);
|
|
||||||
|
|
||||||
-- ─────────────────────────────────────────────
|
|
||||||
-- SHOPPING LIST ITEMS
|
|
||||||
-- ─────────────────────────────────────────────
|
|
||||||
CREATE TABLE IF NOT EXISTS shopping_list_items (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
shopping_list_id UUID NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE,
|
|
||||||
item_name VARCHAR(200) NOT NULL,
|
|
||||||
item_name_lower VARCHAR(200) GENERATED ALWAYS AS (LOWER(item_name)) STORED,
|
|
||||||
quantity DECIMAL(10,4) NOT NULL CHECK (quantity > 0),
|
|
||||||
unit VARCHAR(50) NOT NULL,
|
|
||||||
checked_off BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
last_modified TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_shopping_list_items_merge
|
|
||||||
ON shopping_list_items (shopping_list_id, item_name_lower, unit);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_shopping_list_items_list
|
|
||||||
ON shopping_list_items (shopping_list_id);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_shopping_list_items_last_modified
|
|
||||||
ON shopping_list_items (shopping_list_id, last_modified);
|
|
||||||
|
|
||||||
-- ─────────────────────────────────────────────
|
|
||||||
-- PASSWORD RESET TOKENS
|
|
||||||
-- ─────────────────────────────────────────────
|
|
||||||
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
token_hash VARCHAR(255) NOT NULL,
|
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
|
||||||
used_at TIMESTAMPTZ NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_password_reset_token_hash
|
|
||||||
ON password_reset_tokens (token_hash);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_password_reset_user
|
|
||||||
ON password_reset_tokens (user_id);
|
|
||||||
|
|
||||||
-- ─────────────────────────────────────────────
|
|
||||||
-- DELETED RECORDS (tombstones for sync)
|
|
||||||
-- ─────────────────────────────────────────────
|
|
||||||
CREATE TABLE IF NOT EXISTS deleted_records (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
record_type VARCHAR(50) NOT NULL,
|
|
||||||
record_id UUID NOT NULL,
|
|
||||||
deleted_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_deleted_records_user
|
|
||||||
ON deleted_records (user_id, deleted_at);
|
|
||||||
|
|
||||||
-- ─────────────────────────────────────────────
|
|
||||||
-- updated_at TRIGGER
|
|
||||||
-- ─────────────────────────────────────────────
|
|
||||||
CREATE OR REPLACE FUNCTION set_updated_at()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
NEW.updated_at = NOW();
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_users_updated_at
|
|
||||||
BEFORE UPDATE ON users
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_pantry_updated_at
|
|
||||||
BEFORE UPDATE ON pantry_items
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_recipes_updated_at
|
|
||||||
BEFORE UPDATE ON recipes
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_shopping_lists_updated_at
|
|
||||||
BEFORE UPDATE ON shopping_lists
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_shopping_list_items_updated_at
|
|
||||||
BEFORE UPDATE ON shopping_list_items
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
|
||||||
18
src/db/migrations/002_create_password_reset_tokens.ts
Normal file
18
src/db/migrations/002_create_password_reset_tokens.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Knex } from 'knex';
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
await knex.schema.createTable('password_reset_tokens', (table) => {
|
||||||
|
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
|
||||||
|
table.uuid('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE');
|
||||||
|
table.string('token_hash', 255).notNullable();
|
||||||
|
table.timestamp('expires_at', { useTz: true }).notNullable();
|
||||||
|
table.timestamp('used_at', { useTz: true }).nullable();
|
||||||
|
table.timestamp('created_at', { useTz: true }).defaultTo(knex.fn.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex.raw(`CREATE INDEX idx_prt_user_id ON password_reset_tokens (user_id)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
await knex.schema.dropTableIfExists('password_reset_tokens');
|
||||||
|
}
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
-- Seed: 002_seed_recipes.sql
|
|
||||||
-- 50 pre-populated recipes for Pantree Phase 1.
|
|
||||||
-- Recipes are read-only in Phase 1 — users cannot create or modify them.
|
|
||||||
|
|
||||||
INSERT INTO recipes (id, name, instructions, servings) VALUES
|
|
||||||
('00000000-0000-0000-0000-000000000001', 'Classic Pancakes',
|
|
||||||
E'1. Whisk together flour, baking powder, salt, and sugar.\n2. In a separate bowl, mix milk, egg, and melted butter.\n3. Combine wet and dry ingredients until just mixed.\n4. Cook on a greased griddle over medium heat until bubbles form, then flip.\n5. Serve warm with maple syrup.',
|
|
||||||
4),
|
|
||||||
('00000000-0000-0000-0000-000000000002', 'Scrambled Eggs',
|
|
||||||
E'1. Crack eggs into a bowl, add milk, salt, and pepper. Whisk well.\n2. Melt butter in a non-stick pan over low heat.\n3. Pour in egg mixture and stir gently with a spatula.\n4. Remove from heat while still slightly wet — residual heat finishes cooking.\n5. Serve immediately.',
|
|
||||||
2),
|
|
||||||
('00000000-0000-0000-0000-000000000003', 'Spaghetti Bolognese',
|
|
||||||
E'1. Brown ground beef in a large pan over medium-high heat. Drain fat.\n2. Add diced onion and garlic; cook until softened.\n3. Stir in tomato paste, crushed tomatoes, oregano, salt, and pepper.\n4. Simmer for 20 minutes.\n5. Cook spaghetti per package instructions.\n6. Serve sauce over pasta with grated parmesan.',
|
|
||||||
4),
|
|
||||||
('00000000-0000-0000-0000-000000000004', 'Caesar Salad',
|
|
||||||
E'1. Tear romaine lettuce into bite-sized pieces.\n2. Whisk together olive oil, lemon juice, garlic, worcestershire sauce, and parmesan.\n3. Toss lettuce with dressing.\n4. Top with croutons and extra parmesan.\n5. Season with black pepper.',
|
|
||||||
2),
|
|
||||||
('00000000-0000-0000-0000-000000000005', 'Chicken Stir Fry',
|
|
||||||
E'1. Slice chicken breast into thin strips. Season with salt and pepper.\n2. Heat oil in a wok over high heat.\n3. Cook chicken until golden, about 5 minutes. Remove.\n4. Stir fry vegetables (bell pepper, broccoli, carrot) for 3 minutes.\n5. Return chicken, add soy sauce and sesame oil.\n6. Serve over steamed rice.',
|
|
||||||
3),
|
|
||||||
('00000000-0000-0000-0000-000000000006', 'Banana Bread',
|
|
||||||
E'1. Preheat oven to 350°F (175°C). Grease a loaf pan.\n2. Mash 3 ripe bananas in a bowl.\n3. Mix in melted butter, sugar, egg, and vanilla.\n4. Stir in flour, baking soda, and salt until just combined.\n5. Pour into pan and bake 60 minutes until a toothpick comes out clean.',
|
|
||||||
8),
|
|
||||||
('00000000-0000-0000-0000-000000000007', 'Tomato Soup',
|
|
||||||
E'1. Sauté diced onion and garlic in butter until soft.\n2. Add crushed tomatoes, chicken broth, sugar, salt, and pepper.\n3. Simmer 15 minutes.\n4. Blend until smooth with an immersion blender.\n5. Stir in heavy cream. Adjust seasoning.\n6. Serve with crusty bread.',
|
|
||||||
4),
|
|
||||||
('00000000-0000-0000-0000-000000000008', 'Grilled Cheese Sandwich',
|
|
||||||
E'1. Butter one side of each bread slice.\n2. Place one slice butter-side down in a pan over medium heat.\n3. Layer cheese slices on top.\n4. Place second slice butter-side up.\n5. Cook until golden, about 3 minutes per side.\n6. Slice diagonally and serve.',
|
|
||||||
1),
|
|
||||||
('00000000-0000-0000-0000-000000000009', 'Chocolate Chip Cookies',
|
|
||||||
E'1. Preheat oven to 375°F (190°C).\n2. Cream butter and sugars until fluffy.\n3. Beat in eggs and vanilla.\n4. Mix in flour, baking soda, and salt.\n5. Fold in chocolate chips.\n6. Drop spoonfuls onto baking sheet.\n7. Bake 9–11 minutes until edges are golden.',
|
|
||||||
24),
|
|
||||||
('00000000-0000-0000-0000-000000000010', 'Guacamole',
|
|
||||||
E'1. Halve and pit avocados. Scoop flesh into a bowl.\n2. Mash with a fork to desired consistency.\n3. Stir in lime juice, salt, diced onion, cilantro, and jalapeño.\n4. Taste and adjust seasoning.\n5. Serve immediately with tortilla chips.',
|
|
||||||
4),
|
|
||||||
('00000000-0000-0000-0000-000000000011', 'French Toast',
|
|
||||||
E'1. Whisk eggs, milk, cinnamon, and vanilla in a shallow bowl.\n2. Dip bread slices in egg mixture, coating both sides.\n3. Cook in buttered pan over medium heat until golden, about 2 minutes per side.\n4. Serve with powdered sugar and maple syrup.',
|
|
||||||
2),
|
|
||||||
('00000000-0000-0000-0000-000000000012', 'Chicken Soup',
|
|
||||||
E'1. Simmer chicken thighs in water with salt for 30 minutes. Remove and shred.\n2. Add diced carrots, celery, and onion to broth.\n3. Cook vegetables until tender, about 15 minutes.\n4. Return shredded chicken to pot.\n5. Add egg noodles and cook per package instructions.\n6. Season with salt, pepper, and parsley.',
|
|
||||||
6),
|
|
||||||
('00000000-0000-0000-0000-000000000013', 'Fried Rice',
|
|
||||||
E'1. Cook rice and let cool completely (day-old rice works best).\n2. Scramble eggs in a wok with a little oil. Remove.\n3. Stir fry diced onion, carrot, and peas in oil.\n4. Add rice and stir fry over high heat.\n5. Return eggs, add soy sauce and sesame oil.\n6. Toss and serve.',
|
|
||||||
3),
|
|
||||||
('00000000-0000-0000-0000-000000000014', 'Beef Tacos',
|
|
||||||
E'1. Brown ground beef with diced onion and garlic.\n2. Season with cumin, chili powder, salt, and pepper.\n3. Warm taco shells in oven.\n4. Fill shells with beef, shredded cheese, lettuce, and tomato.\n5. Top with sour cream and salsa.',
|
|
||||||
4),
|
|
||||||
('00000000-0000-0000-0000-000000000015', 'Oatmeal',
|
|
||||||
E'1. Bring water or milk to a boil in a saucepan.\n2. Stir in oats and reduce heat to medium.\n3. Cook, stirring occasionally, for 5 minutes.\n4. Remove from heat and let stand 2 minutes.\n5. Top with brown sugar, cinnamon, and fruit.',
|
|
||||||
2),
|
|
||||||
('00000000-0000-0000-0000-000000000016', 'Pasta Carbonara',
|
|
||||||
E'1. Cook spaghetti in salted boiling water until al dente.\n2. Fry diced pancetta until crispy.\n3. Whisk eggs, parmesan, and black pepper in a bowl.\n4. Drain pasta, reserving 1 cup pasta water.\n5. Off heat, toss pasta with pancetta, then egg mixture, adding pasta water to loosen.\n6. Serve immediately with extra parmesan.',
|
|
||||||
2),
|
|
||||||
('00000000-0000-0000-0000-000000000017', 'Vegetable Curry',
|
|
||||||
E'1. Sauté diced onion, garlic, and ginger in oil.\n2. Add curry powder, cumin, and turmeric; cook 1 minute.\n3. Add diced potato, cauliflower, and chickpeas.\n4. Pour in coconut milk and vegetable broth.\n5. Simmer 20 minutes until vegetables are tender.\n6. Serve over basmati rice.',
|
|
||||||
4),
|
|
||||||
('00000000-0000-0000-0000-000000000018', 'BLT Sandwich',
|
|
||||||
E'1. Cook bacon until crispy.\n2. Toast bread slices.\n3. Spread mayonnaise on both slices.\n4. Layer lettuce, tomato slices, and bacon.\n5. Season with salt and pepper.\n6. Close sandwich and slice in half.',
|
|
||||||
1),
|
|
||||||
('00000000-0000-0000-0000-000000000019', 'Lemon Garlic Shrimp',
|
|
||||||
E'1. Season shrimp with salt, pepper, and paprika.\n2. Melt butter in a pan over medium-high heat.\n3. Add minced garlic and cook 30 seconds.\n4. Add shrimp and cook 2 minutes per side.\n5. Squeeze lemon juice over shrimp.\n6. Garnish with parsley and serve over pasta or rice.',
|
|
||||||
2),
|
|
||||||
('00000000-0000-0000-0000-000000000020', 'Apple Pie',
|
|
||||||
E'1. Preheat oven to 425°F (220°C).\n2. Peel, core, and slice apples. Toss with sugar, cinnamon, and flour.\n3. Line pie dish with bottom crust.\n4. Fill with apple mixture and dot with butter.\n5. Cover with top crust, seal edges, and cut vents.\n6. Bake 45–50 minutes until golden.',
|
|
||||||
8),
|
|
||||||
('00000000-0000-0000-0000-000000000021', 'Minestrone Soup',
|
|
||||||
E'1. Sauté onion, carrot, and celery in olive oil.\n2. Add garlic, diced tomatoes, and tomato paste.\n3. Pour in vegetable broth. Add kidney beans and zucchini.\n4. Simmer 20 minutes.\n5. Add small pasta and cook until tender.\n6. Season with salt, pepper, and basil.',
|
|
||||||
6),
|
|
||||||
('00000000-0000-0000-0000-000000000022', 'Quesadillas',
|
|
||||||
E'1. Place a flour tortilla in a dry pan over medium heat.\n2. Sprinkle shredded cheese on one half.\n3. Add diced chicken, peppers, and onion.\n4. Fold tortilla in half and cook until golden, about 2 minutes per side.\n5. Slice into wedges and serve with salsa and sour cream.',
|
|
||||||
2),
|
|
||||||
('00000000-0000-0000-0000-000000000023', 'Baked Salmon',
|
|
||||||
E'1. Preheat oven to 400°F (200°C).\n2. Place salmon fillets on a lined baking sheet.\n3. Brush with olive oil, lemon juice, garlic, and dill.\n4. Season with salt and pepper.\n5. Bake 12–15 minutes until fish flakes easily.\n6. Serve with roasted vegetables.',
|
|
||||||
2),
|
|
||||||
('00000000-0000-0000-0000-000000000024', 'Hummus',
|
|
||||||
E'1. Drain and rinse chickpeas, reserving liquid.\n2. Blend chickpeas, tahini, lemon juice, garlic, and salt in a food processor.\n3. Add reserved liquid or water to reach desired consistency.\n4. Drizzle with olive oil and sprinkle with paprika.\n5. Serve with pita bread or vegetables.',
|
|
||||||
6),
|
|
||||||
('00000000-0000-0000-0000-000000000025', 'Beef Stew',
|
|
||||||
E'1. Brown beef chunks in oil in a Dutch oven. Remove.\n2. Sauté onion, carrot, and celery until softened.\n3. Add garlic, tomato paste, and flour; cook 1 minute.\n4. Return beef, add beef broth, potatoes, and thyme.\n5. Simmer covered for 1.5 hours until beef is tender.\n6. Season and serve.',
|
|
||||||
6),
|
|
||||||
('00000000-0000-0000-0000-000000000026', 'Smoothie Bowl',
|
|
||||||
E'1. Blend frozen banana, frozen berries, and milk until thick.\n2. Pour into a bowl.\n3. Top with granola, sliced fresh fruit, and honey.\n4. Add chia seeds and coconut flakes if desired.\n5. Serve immediately.',
|
|
||||||
1),
|
|
||||||
('00000000-0000-0000-0000-000000000027', 'Shakshuka',
|
|
||||||
E'1. Sauté diced onion and bell pepper in olive oil.\n2. Add garlic, cumin, paprika, and chili flakes; cook 1 minute.\n3. Pour in crushed tomatoes. Simmer 10 minutes.\n4. Make wells in the sauce and crack eggs into them.\n5. Cover and cook until whites are set but yolks are runny.\n6. Garnish with feta and parsley.',
|
|
||||||
2),
|
|
||||||
('00000000-0000-0000-0000-000000000028', 'Pesto Pasta',
|
|
||||||
E'1. Cook pasta in salted boiling water until al dente.\n2. Blend basil, pine nuts, parmesan, garlic, and olive oil into pesto.\n3. Drain pasta, reserving ½ cup pasta water.\n4. Toss pasta with pesto, adding pasta water to loosen.\n5. Season with salt and pepper.\n6. Serve with extra parmesan.',
|
|
||||||
3),
|
|
||||||
('00000000-0000-0000-0000-000000000029', 'Roast Chicken',
|
|
||||||
E'1. Preheat oven to 425°F (220°C).\n2. Pat chicken dry. Rub with butter, garlic, salt, pepper, and herbs.\n3. Stuff cavity with lemon halves and fresh thyme.\n4. Roast breast-side up for 1 hour 15 minutes.\n5. Rest 10 minutes before carving.\n6. Serve with pan juices.',
|
|
||||||
4),
|
|
||||||
('00000000-0000-0000-0000-000000000030', 'Chocolate Brownies',
|
|
||||||
E'1. Preheat oven to 350°F (175°C). Grease an 8x8 pan.\n2. Melt butter and chocolate together.\n3. Whisk in sugar, eggs, and vanilla.\n4. Fold in flour, cocoa powder, and salt.\n5. Pour into pan and bake 25–30 minutes.\n6. Cool completely before cutting.',
|
|
||||||
16),
|
|
||||||
('00000000-0000-0000-0000-000000000031', 'Greek Salad',
|
|
||||||
E'1. Chop cucumber, tomatoes, and red onion.\n2. Add kalamata olives and cubed feta cheese.\n3. Drizzle with olive oil and red wine vinegar.\n4. Season with oregano, salt, and pepper.\n5. Toss gently and serve.',
|
|
||||||
2),
|
|
||||||
('00000000-0000-0000-0000-000000000032', 'Mashed Potatoes',
|
|
||||||
E'1. Peel and cube potatoes. Boil in salted water until tender, about 15 minutes.\n2. Drain and return to pot.\n3. Mash with butter and warm milk until smooth.\n4. Season generously with salt and pepper.\n5. Serve immediately.',
|
|
||||||
4),
|
|
||||||
('00000000-0000-0000-0000-000000000033', 'Lentil Soup',
|
|
||||||
E'1. Sauté diced onion, carrot, and celery in olive oil.\n2. Add garlic, cumin, and turmeric; cook 1 minute.\n3. Add rinsed red lentils and vegetable broth.\n4. Simmer 25 minutes until lentils are soft.\n5. Blend partially for a creamy texture.\n6. Season with lemon juice, salt, and pepper.',
|
|
||||||
4),
|
|
||||||
('00000000-0000-0000-0000-000000000034', 'Avocado Toast',
|
|
||||||
E'1. Toast bread slices until golden.\n2. Mash avocado with lemon juice, salt, and pepper.\n3. Spread avocado on toast.\n4. Top with red pepper flakes and everything bagel seasoning.\n5. Optional: add a poached egg on top.',
|
|
||||||
1),
|
|
||||||
('00000000-0000-0000-0000-000000000035', 'Chicken Tikka Masala',
|
|
||||||
E'1. Marinate chicken in yogurt, lemon juice, and spices for 1 hour.\n2. Grill or broil chicken until charred. Set aside.\n3. Sauté onion, garlic, and ginger in oil.\n4. Add tomato purée, cream, and spices. Simmer 10 minutes.\n5. Add chicken and simmer 10 more minutes.\n6. Serve over basmati rice with naan.',
|
|
||||||
4),
|
|
||||||
('00000000-0000-0000-0000-000000000036', 'Waffles',
|
|
||||||
E'1. Preheat waffle iron.\n2. Whisk flour, baking powder, sugar, and salt.\n3. Mix milk, eggs, and melted butter separately.\n4. Combine wet and dry ingredients until just mixed.\n5. Pour batter into waffle iron and cook until golden.\n6. Serve with butter and maple syrup.',
|
|
||||||
4),
|
|
||||||
('00000000-0000-0000-0000-000000000037', 'Tuna Salad',
|
|
||||||
E'1. Drain canned tuna and flake into a bowl.\n2. Add mayonnaise, diced celery, and red onion.\n3. Season with lemon juice, salt, and pepper.\n4. Mix well.\n5. Serve on bread, crackers, or lettuce cups.',
|
|
||||||
2),
|
|
||||||
('00000000-0000-0000-0000-000000000038', 'Mushroom Risotto',
|
|
||||||
E'1. Sauté diced onion in butter until soft.\n2. Add arborio rice and toast 1 minute.\n3. Add white wine and stir until absorbed.\n4. Add warm broth one ladle at a time, stirring constantly.\n5. Stir in sautéed mushrooms and parmesan.\n6. Season with salt, pepper, and fresh thyme.',
|
|
||||||
3),
|
|
||||||
('00000000-0000-0000-0000-000000000039', 'Blueberry Muffins',
|
|
||||||
E'1. Preheat oven to 375°F (190°C). Line muffin tin.\n2. Whisk flour, sugar, baking powder, and salt.\n3. Mix milk, egg, and melted butter.\n4. Combine wet and dry ingredients until just mixed.\n5. Fold in blueberries.\n6. Fill muffin cups ¾ full and bake 20–25 minutes.',
|
|
||||||
12),
|
|
||||||
('00000000-0000-0000-0000-000000000040', 'Pad Thai',
|
|
||||||
E'1. Soak rice noodles in warm water 30 minutes. Drain.\n2. Stir fry shrimp or chicken in oil. Remove.\n3. Scramble eggs in the same pan.\n4. Add noodles, protein, bean sprouts, and green onions.\n5. Pour in pad thai sauce (fish sauce, tamarind, sugar).\n6. Toss and serve with lime, peanuts, and chili flakes.',
|
|
||||||
2),
|
|
||||||
('00000000-0000-0000-0000-000000000041', 'Corn Chowder',
|
|
||||||
E'1. Cook diced bacon until crispy. Remove.\n2. Sauté onion and celery in bacon fat.\n3. Add diced potato, corn kernels, and chicken broth.\n4. Simmer 15 minutes until potato is tender.\n5. Stir in heavy cream and season.\n6. Top with bacon and chives.',
|
|
||||||
4),
|
|
||||||
('00000000-0000-0000-0000-000000000042', 'Egg Fried Noodles',
|
|
||||||
E'1. Cook noodles per package instructions. Drain.\n2. Scramble eggs in a wok with oil. Remove.\n3. Stir fry garlic and green onion.\n4. Add noodles and toss over high heat.\n5. Return eggs, add soy sauce and sesame oil.\n6. Serve immediately.',
|
|
||||||
2),
|
|
||||||
('00000000-0000-0000-0000-000000000043', 'Caprese Salad',
|
|
||||||
E'1. Slice fresh mozzarella and tomatoes.\n2. Arrange alternating slices on a plate.\n3. Tuck fresh basil leaves between slices.\n4. Drizzle with olive oil and balsamic glaze.\n5. Season with salt and black pepper.',
|
|
||||||
2),
|
|
||||||
('00000000-0000-0000-0000-000000000044', 'Baked Mac and Cheese',
|
|
||||||
E'1. Preheat oven to 375°F (190°C).\n2. Cook macaroni until al dente. Drain.\n3. Make cheese sauce: melt butter, whisk in flour, add milk, stir in cheddar.\n4. Combine pasta and sauce. Pour into baking dish.\n5. Top with breadcrumbs and extra cheese.\n6. Bake 25 minutes until bubbly and golden.',
|
|
||||||
6),
|
|
||||||
('00000000-0000-0000-0000-000000000045', 'Chicken Fajitas',
|
|
||||||
E'1. Slice chicken breast into strips. Season with fajita spices.\n2. Cook chicken in a hot pan until cooked through. Remove.\n3. Sauté sliced bell peppers and onion until tender.\n4. Warm flour tortillas.\n5. Serve chicken and vegetables in tortillas.\n6. Top with sour cream, guacamole, and salsa.',
|
|
||||||
3),
|
|
||||||
('00000000-0000-0000-0000-000000000046', 'Overnight Oats',
|
|
||||||
E'1. Combine rolled oats, milk, and yogurt in a jar.\n2. Add chia seeds and honey. Stir well.\n3. Cover and refrigerate overnight.\n4. In the morning, top with fresh berries and nuts.\n5. Serve cold or warm briefly in microwave.',
|
|
||||||
1),
|
|
||||||
('00000000-0000-0000-0000-000000000047', 'Stuffed Bell Peppers',
|
|
||||||
E'1. Preheat oven to 375°F (190°C).\n2. Cut tops off peppers and remove seeds.\n3. Brown ground beef with onion and garlic.\n4. Mix with cooked rice, tomato sauce, and seasoning.\n5. Fill peppers with mixture. Top with cheese.\n6. Bake 30–35 minutes until peppers are tender.',
|
|
||||||
4),
|
|
||||||
('00000000-0000-0000-0000-000000000048', 'Garlic Bread',
|
|
||||||
E'1. Preheat oven to 375°F (190°C).\n2. Mix softened butter with minced garlic, parsley, and salt.\n3. Slice baguette in half lengthwise.\n4. Spread garlic butter generously on cut sides.\n5. Bake 10 minutes, then broil 2 minutes until golden.\n6. Slice and serve immediately.',
|
|
||||||
4),
|
|
||||||
('00000000-0000-0000-0000-000000000049', 'Spinach and Feta Omelette',
|
|
||||||
E'1. Whisk eggs with salt and pepper.\n2. Melt butter in a non-stick pan over medium heat.\n3. Pour in eggs and cook until edges set.\n4. Add fresh spinach and crumbled feta to one half.\n5. Fold omelette in half and cook 1 more minute.\n6. Slide onto plate and serve.',
|
|
||||||
1),
|
|
||||||
('00000000-0000-0000-0000-000000000050', 'Chocolate Mousse',
|
|
||||||
E'1. Melt dark chocolate and let cool slightly.\n2. Whip heavy cream to soft peaks.\n3. Whisk egg yolks with sugar until pale.\n4. Fold chocolate into egg mixture.\n5. Fold in whipped cream gently.\n6. Chill 2 hours before serving.',
|
|
||||||
4);
|
|
||||||
|
|
||||||
-- ─────────────────────────────────────────────
|
|
||||||
-- RECIPE INGREDIENTS
|
|
||||||
-- ─────────────────────────────────────────────
|
|
||||||
INSERT INTO recipe_ingredients (recipe_id, item_name, quantity, unit) VALUES
|
|
||||||
-- Classic Pancakes
|
|
||||||
('00000000-0000-0000-0000-000000000001', 'flour', 2.0, 'cups'),
|
|
||||||
('00000000-0000-0000-0000-000000000001', 'baking powder', 2.0, 'tsp'),
|
|
||||||
('00000000-0000-0000-0000-000000000001', 'salt', 0.5, 'tsp'),
|
|
||||||
('00000000-0000-0000-0000-000000000001', 'sugar', 2.0, 'tbsp'),
|
|
||||||
('00000000-0000-0000-0000-000000000001', 'milk', 1.5, 'cups'),
|
|
||||||
('00000000-0000-0000-0000-000000000001', 'egg', 1.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000001', 'butter', 2.0, 'tbsp'),
|
|
||||||
-- Scrambled Eggs
|
|
||||||
('00000000-0000-0000-0000-000000000002', 'eggs', 4.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000002', 'milk', 2.0, 'tbsp'),
|
|
||||||
('00000000-0000-0000-0000-000000000002', 'butter', 1.0, 'tbsp'),
|
|
||||||
-- Spaghetti Bolognese
|
|
||||||
('00000000-0000-0000-0000-000000000003', 'spaghetti', 400.0, 'g'),
|
|
||||||
('00000000-0000-0000-0000-000000000003', 'ground beef', 500.0, 'g'),
|
|
||||||
('00000000-0000-0000-0000-000000000003', 'onion', 1.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000003', 'garlic', 3.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000003', 'crushed tomatoes', 400.0, 'g'),
|
|
||||||
('00000000-0000-0000-0000-000000000003', 'tomato paste', 2.0, 'tbsp'),
|
|
||||||
('00000000-0000-0000-0000-000000000003', 'parmesan', 50.0, 'g'),
|
|
||||||
-- Caesar Salad
|
|
||||||
('00000000-0000-0000-0000-000000000004', 'romaine lettuce', 1.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000004', 'olive oil', 3.0, 'tbsp'),
|
|
||||||
('00000000-0000-0000-0000-000000000004', 'lemon', 1.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000004', 'garlic', 1.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000004', 'parmesan', 30.0, 'g'),
|
|
||||||
-- Chicken Stir Fry
|
|
||||||
('00000000-0000-0000-0000-000000000005', 'chicken breast', 400.0, 'g'),
|
|
||||||
('00000000-0000-0000-0000-000000000005', 'bell pepper', 1.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000005', 'broccoli', 200.0, 'g'),
|
|
||||||
('00000000-0000-0000-0000-000000000005', 'soy sauce', 3.0, 'tbsp'),
|
|
||||||
('00000000-0000-0000-0000-000000000005', 'sesame oil', 1.0, 'tbsp'),
|
|
||||||
('00000000-0000-0000-0000-000000000005', 'rice', 2.0, 'cups'),
|
|
||||||
-- Banana Bread
|
|
||||||
('00000000-0000-0000-0000-000000000006', 'banana', 3.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000006', 'flour', 1.5, 'cups'),
|
|
||||||
('00000000-0000-0000-0000-000000000006', 'sugar', 0.75, 'cups'),
|
|
||||||
('00000000-0000-0000-0000-000000000006', 'butter', 0.3333, 'cups'),
|
|
||||||
('00000000-0000-0000-0000-000000000006', 'egg', 1.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000006', 'baking soda', 1.0, 'tsp'),
|
|
||||||
-- Tomato Soup
|
|
||||||
('00000000-0000-0000-0000-000000000007', 'crushed tomatoes', 800.0, 'g'),
|
|
||||||
('00000000-0000-0000-0000-000000000007', 'onion', 1.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000007', 'garlic', 2.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000007', 'butter', 2.0, 'tbsp'),
|
|
||||||
('00000000-0000-0000-0000-000000000007', 'heavy cream', 0.5, 'cups'),
|
|
||||||
-- Grilled Cheese
|
|
||||||
('00000000-0000-0000-0000-000000000008', 'bread', 2.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000008', 'cheddar cheese', 60.0, 'g'),
|
|
||||||
('00000000-0000-0000-0000-000000000008', 'butter', 1.0, 'tbsp'),
|
|
||||||
-- Chocolate Chip Cookies
|
|
||||||
('00000000-0000-0000-0000-000000000009', 'flour', 2.25, 'cups'),
|
|
||||||
('00000000-0000-0000-0000-000000000009', 'butter', 1.0, 'cups'),
|
|
||||||
('00000000-0000-0000-0000-000000000009', 'sugar', 0.75, 'cups'),
|
|
||||||
('00000000-0000-0000-0000-000000000009', 'brown sugar', 0.75, 'cups'),
|
|
||||||
('00000000-0000-0000-0000-000000000009', 'eggs', 2.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000009', 'vanilla extract', 1.0, 'tsp'),
|
|
||||||
('00000000-0000-0000-0000-000000000009', 'baking soda', 1.0, 'tsp'),
|
|
||||||
('00000000-0000-0000-0000-000000000009', 'chocolate chips', 2.0, 'cups'),
|
|
||||||
-- Guacamole
|
|
||||||
('00000000-0000-0000-0000-000000000010', 'avocado', 3.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000010', 'lime', 1.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000010', 'onion', 0.5, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000010', 'cilantro', 2.0, 'tbsp'),
|
|
||||||
-- French Toast
|
|
||||||
('00000000-0000-0000-0000-000000000011', 'bread', 4.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000011', 'eggs', 2.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000011', 'milk', 0.25, 'cups'),
|
|
||||||
('00000000-0000-0000-0000-000000000011', 'butter', 1.0, 'tbsp'),
|
|
||||||
('00000000-0000-0000-0000-000000000011', 'cinnamon', 0.5, 'tsp'),
|
|
||||||
-- Chicken Soup
|
|
||||||
('00000000-0000-0000-0000-000000000012', 'chicken thighs', 600.0, 'g'),
|
|
||||||
('00000000-0000-0000-0000-000000000012', 'carrot', 2.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000012', 'celery', 2.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000012', 'onion', 1.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000012', 'egg noodles', 200.0, 'g'),
|
|
||||||
-- Fried Rice
|
|
||||||
('00000000-0000-0000-0000-000000000013', 'rice', 2.0, 'cups'),
|
|
||||||
('00000000-0000-0000-0000-000000000013', 'eggs', 2.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000013', 'soy sauce', 3.0, 'tbsp'),
|
|
||||||
('00000000-0000-0000-0000-000000000013', 'sesame oil', 1.0, 'tbsp'),
|
|
||||||
('00000000-0000-0000-0000-000000000013', 'onion', 1.0, 'whole'),
|
|
||||||
-- Beef Tacos
|
|
||||||
('00000000-0000-0000-0000-000000000014', 'ground beef', 400.0, 'g'),
|
|
||||||
('00000000-0000-0000-0000-000000000014', 'taco shells', 8.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000014', 'cheddar cheese', 100.0, 'g'),
|
|
||||||
('00000000-0000-0000-0000-000000000014', 'onion', 1.0, 'whole'),
|
|
||||||
-- Oatmeal
|
|
||||||
('00000000-0000-0000-0000-000000000015', 'rolled oats', 1.0, 'cups'),
|
|
||||||
('00000000-0000-0000-0000-000000000015', 'milk', 2.0, 'cups'),
|
|
||||||
('00000000-0000-0000-0000-000000000015', 'brown sugar', 2.0, 'tbsp'),
|
|
||||||
('00000000-0000-0000-0000-000000000015', 'cinnamon', 0.5, 'tsp'),
|
|
||||||
-- Pasta Carbonara
|
|
||||||
('00000000-0000-0000-0000-000000000016', 'spaghetti', 200.0, 'g'),
|
|
||||||
('00000000-0000-0000-0000-000000000016', 'eggs', 3.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000016', 'parmesan', 80.0, 'g'),
|
|
||||||
('00000000-0000-0000-0000-000000000016', 'pancetta', 150.0, 'g'),
|
|
||||||
-- Vegetable Curry
|
|
||||||
('00000000-0000-0000-0000-000000000017', 'potato', 2.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000017', 'cauliflower', 0.5, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000017', 'chickpeas', 400.0, 'g'),
|
|
||||||
('00000000-0000-0000-0000-000000000017', 'coconut milk', 400.0, 'ml'),
|
|
||||||
('00000000-0000-0000-0000-000000000017', 'onion', 1.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000017', 'garlic', 3.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000017', 'rice', 2.0, 'cups'),
|
|
||||||
-- BLT Sandwich
|
|
||||||
('00000000-0000-0000-0000-000000000018', 'bread', 2.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000018', 'bacon', 4.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000018', 'lettuce', 2.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000018', 'tomato', 1.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000018', 'mayonnaise', 2.0, 'tbsp'),
|
|
||||||
-- Lemon Garlic Shrimp
|
|
||||||
('00000000-0000-0000-0000-000000000019', 'shrimp', 400.0, 'g'),
|
|
||||||
('00000000-0000-0000-0000-000000000019', 'butter', 3.0, 'tbsp'),
|
|
||||||
('00000000-0000-0000-0000-000000000019', 'garlic', 4.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000019', 'lemon', 1.0, 'whole'),
|
|
||||||
-- Apple Pie
|
|
||||||
('00000000-0000-0000-0000-000000000020', 'apple', 6.0, 'whole'),
|
|
||||||
('00000000-0000-0000-0000-000000000020', 'flour', 2.5, 'cups'),
|
|
||||||
('00000000-0000-0000-0000-000000000020', 'butter', 1.0, 'cups'),
|
|
||||||
('00000000-0000-0000-0000-000000000020', 'sugar', 0.75, 'cups'),
|
|
||||||
('00000000-0000-0000-0000-000000000020', 'cinnamon', 1.0, 'tsp');
|
|
||||||
22
src/db/migrations/003_create_pantry_items.ts
Normal file
22
src/db/migrations/003_create_pantry_items.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Knex } from 'knex';
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
await knex.schema.createTable('pantry_items', (table) => {
|
||||||
|
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
|
||||||
|
table.uuid('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE');
|
||||||
|
table.string('item_name', 255).notNullable();
|
||||||
|
table.string('item_name_lower', 255).notNullable();
|
||||||
|
table.integer('quantity').notNullable().checkPositive();
|
||||||
|
table.timestamp('last_modified', { useTz: true }).defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('created_at', { useTz: true }).defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updated_at', { useTz: true }).defaultTo(knex.fn.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex.raw(`CREATE UNIQUE INDEX idx_pantry_user_item ON pantry_items (user_id, item_name_lower)`);
|
||||||
|
await knex.raw(`CREATE INDEX idx_pantry_user_id ON pantry_items (user_id)`);
|
||||||
|
await knex.raw(`CREATE INDEX idx_pantry_last_modified ON pantry_items (user_id, last_modified)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
await knex.schema.dropTableIfExists('pantry_items');
|
||||||
|
}
|
||||||
30
src/db/migrations/004_create_recipes.ts
Normal file
30
src/db/migrations/004_create_recipes.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { Knex } from 'knex';
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
await knex.schema.createTable('recipes', (table) => {
|
||||||
|
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
|
||||||
|
table.string('name', 255).notNullable();
|
||||||
|
table.text('instructions').notNullable();
|
||||||
|
table.integer('servings').notNullable().checkPositive();
|
||||||
|
table.timestamp('created_at', { useTz: true }).defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updated_at', { useTz: true }).defaultTo(knex.fn.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex.schema.createTable('recipe_ingredients', (table) => {
|
||||||
|
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
|
||||||
|
table.uuid('recipe_id').notNullable().references('id').inTable('recipes').onDelete('CASCADE');
|
||||||
|
table.string('item_name', 255).notNullable();
|
||||||
|
table.string('item_name_lower', 255).notNullable();
|
||||||
|
table.decimal('quantity', 10, 4).notNullable().checkPositive();
|
||||||
|
table.string('unit', 50).notNullable();
|
||||||
|
table.timestamp('created_at', { useTz: true }).defaultTo(knex.fn.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex.raw(`CREATE INDEX idx_recipe_ingredients_recipe ON recipe_ingredients (recipe_id)`);
|
||||||
|
await knex.raw(`CREATE INDEX idx_recipe_ingredients_name ON recipe_ingredients (item_name_lower)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
await knex.schema.dropTableIfExists('recipe_ingredients');
|
||||||
|
await knex.schema.dropTableIfExists('recipes');
|
||||||
|
}
|
||||||
36
src/db/migrations/005_create_shopping_lists.ts
Normal file
36
src/db/migrations/005_create_shopping_lists.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Knex } from 'knex';
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
await knex.schema.createTable('shopping_lists', (table) => {
|
||||||
|
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
|
||||||
|
table.uuid('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE');
|
||||||
|
table.string('list_name', 255).notNullable();
|
||||||
|
table.timestamp('last_modified', { useTz: true }).defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('created_at', { useTz: true }).defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updated_at', { useTz: true }).defaultTo(knex.fn.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex.schema.createTable('shopping_list_items', (table) => {
|
||||||
|
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
|
||||||
|
table.uuid('shopping_list_id').notNullable().references('id').inTable('shopping_lists').onDelete('CASCADE');
|
||||||
|
table.string('item_name', 255).notNullable();
|
||||||
|
table.string('item_name_lower', 255).notNullable();
|
||||||
|
table.decimal('quantity', 10, 4).notNullable().checkPositive();
|
||||||
|
table.string('unit', 50).notNullable();
|
||||||
|
table.boolean('checked_off').defaultTo(false);
|
||||||
|
table.timestamp('last_modified', { useTz: true }).defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('created_at', { useTz: true }).defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updated_at', { useTz: true }).defaultTo(knex.fn.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex.raw(`CREATE INDEX idx_shopping_lists_user ON shopping_lists (user_id)`);
|
||||||
|
await knex.raw(`CREATE INDEX idx_shopping_lists_last_modified ON shopping_lists (user_id, last_modified)`);
|
||||||
|
await knex.raw(`CREATE INDEX idx_list_items_list ON shopping_list_items (shopping_list_id)`);
|
||||||
|
await knex.raw(`CREATE INDEX idx_list_items_name_unit ON shopping_list_items (shopping_list_id, item_name_lower, unit)`);
|
||||||
|
await knex.raw(`CREATE INDEX idx_list_items_last_modified ON shopping_list_items (shopping_list_id, last_modified)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
await knex.schema.dropTableIfExists('shopping_list_items');
|
||||||
|
await knex.schema.dropTableIfExists('shopping_lists');
|
||||||
|
}
|
||||||
17
src/db/migrations/006_create_deleted_records.ts
Normal file
17
src/db/migrations/006_create_deleted_records.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Knex } from 'knex';
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
await knex.schema.createTable('deleted_records', (table) => {
|
||||||
|
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
|
||||||
|
table.uuid('user_id').notNullable();
|
||||||
|
table.string('table_name', 50).notNullable();
|
||||||
|
table.uuid('record_id').notNullable();
|
||||||
|
table.timestamp('deleted_at', { useTz: true }).defaultTo(knex.fn.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex.raw(`CREATE INDEX idx_deleted_records_user_time ON deleted_records (user_id, deleted_at)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
await knex.schema.dropTableIfExists('deleted_records');
|
||||||
|
}
|
||||||
164
src/db/seeds/001_recipes.ts
Normal file
164
src/db/seeds/001_recipes.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import type { Knex } from 'knex';
|
||||||
|
|
||||||
|
const recipes = [
|
||||||
|
{
|
||||||
|
name: 'Chocolate Chip Cookies',
|
||||||
|
servings: 24,
|
||||||
|
instructions: '1. Preheat oven to 375°F.\n2. Cream butter and sugars.\n3. Beat in eggs and vanilla.\n4. Mix in flour, baking soda, and salt.\n5. Stir in chocolate chips.\n6. Drop by spoonfuls onto baking sheet.\n7. Bake 9-11 minutes until golden.',
|
||||||
|
ingredients: [
|
||||||
|
{ item_name: 'All-Purpose Flour', quantity: 2.25, unit: 'cups' },
|
||||||
|
{ item_name: 'Butter', quantity: 1, unit: 'cups' },
|
||||||
|
{ item_name: 'Granulated Sugar', quantity: 0.75, unit: 'cups' },
|
||||||
|
{ item_name: 'Brown Sugar', quantity: 0.75, unit: 'cups' },
|
||||||
|
{ item_name: 'Eggs', quantity: 2, unit: 'pieces' },
|
||||||
|
{ item_name: 'Vanilla Extract', quantity: 1, unit: 'tsp' },
|
||||||
|
{ item_name: 'Baking Soda', quantity: 1, unit: 'tsp' },
|
||||||
|
{ item_name: 'Chocolate Chips', quantity: 2, unit: 'cups' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Classic Pancakes',
|
||||||
|
servings: 4,
|
||||||
|
instructions: '1. Mix dry ingredients.\n2. Whisk wet ingredients separately.\n3. Combine wet and dry.\n4. Cook on greased griddle over medium heat.\n5. Flip when bubbles form.',
|
||||||
|
ingredients: [
|
||||||
|
{ item_name: 'All-Purpose Flour', quantity: 1.5, unit: 'cups' },
|
||||||
|
{ item_name: 'Milk', quantity: 1.25, unit: 'cups' },
|
||||||
|
{ item_name: 'Eggs', quantity: 1, unit: 'pieces' },
|
||||||
|
{ item_name: 'Butter', quantity: 3, unit: 'tbsp' },
|
||||||
|
{ item_name: 'Baking Powder', quantity: 2, unit: 'tsp' },
|
||||||
|
{ item_name: 'Granulated Sugar', quantity: 1, unit: 'tbsp' },
|
||||||
|
{ item_name: 'Salt', quantity: 1, unit: 'tsp' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Spaghetti Bolognese',
|
||||||
|
servings: 4,
|
||||||
|
instructions: '1. Brown ground beef.\n2. Add onion and garlic, cook until soft.\n3. Add tomatoes and simmer 30 minutes.\n4. Cook pasta.\n5. Serve sauce over pasta.',
|
||||||
|
ingredients: [
|
||||||
|
{ item_name: 'Spaghetti', quantity: 400, unit: 'g' },
|
||||||
|
{ item_name: 'Ground Beef', quantity: 500, unit: 'g' },
|
||||||
|
{ item_name: 'Onion', quantity: 1, unit: 'whole' },
|
||||||
|
{ item_name: 'Garlic', quantity: 3, unit: 'cloves' },
|
||||||
|
{ item_name: 'Canned Tomatoes', quantity: 2, unit: 'can' },
|
||||||
|
{ item_name: 'Olive Oil', quantity: 2, unit: 'tbsp' },
|
||||||
|
{ item_name: 'Salt', quantity: 1, unit: 'tsp' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Caesar Salad',
|
||||||
|
servings: 2,
|
||||||
|
instructions: '1. Wash and chop romaine.\n2. Make dressing with garlic, lemon, and parmesan.\n3. Toss lettuce with dressing.\n4. Top with croutons and extra parmesan.',
|
||||||
|
ingredients: [
|
||||||
|
{ item_name: 'Romaine Lettuce', quantity: 1, unit: 'whole' },
|
||||||
|
{ item_name: 'Parmesan Cheese', quantity: 0.5, unit: 'cups' },
|
||||||
|
{ item_name: 'Garlic', quantity: 2, unit: 'cloves' },
|
||||||
|
{ item_name: 'Lemon', quantity: 1, unit: 'whole' },
|
||||||
|
{ item_name: 'Olive Oil', quantity: 3, unit: 'tbsp' },
|
||||||
|
{ item_name: 'Croutons', quantity: 1, unit: 'cups' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Banana Bread',
|
||||||
|
servings: 8,
|
||||||
|
instructions: '1. Preheat oven to 350°F.\n2. Mash bananas.\n3. Mix wet ingredients.\n4. Fold in dry ingredients.\n5. Pour into loaf pan.\n6. Bake 60-65 minutes.',
|
||||||
|
ingredients: [
|
||||||
|
{ item_name: 'Ripe Bananas', quantity: 3, unit: 'whole' },
|
||||||
|
{ item_name: 'All-Purpose Flour', quantity: 1.5, unit: 'cups' },
|
||||||
|
{ item_name: 'Butter', quantity: 0.33, unit: 'cups' },
|
||||||
|
{ item_name: 'Granulated Sugar', quantity: 0.75, unit: 'cups' },
|
||||||
|
{ item_name: 'Eggs', quantity: 1, unit: 'pieces' },
|
||||||
|
{ item_name: 'Baking Soda', quantity: 1, unit: 'tsp' },
|
||||||
|
{ item_name: 'Salt', quantity: 0.25, unit: 'tsp' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Chicken Stir Fry',
|
||||||
|
servings: 4,
|
||||||
|
instructions: '1. Slice chicken and vegetables.\n2. Heat oil in wok.\n3. Cook chicken until done.\n4. Add vegetables and stir fry.\n5. Add sauce and toss.\n6. Serve over rice.',
|
||||||
|
ingredients: [
|
||||||
|
{ item_name: 'Chicken Breast', quantity: 500, unit: 'g' },
|
||||||
|
{ item_name: 'Bell Pepper', quantity: 2, unit: 'whole' },
|
||||||
|
{ item_name: 'Broccoli', quantity: 2, unit: 'cups' },
|
||||||
|
{ item_name: 'Soy Sauce', quantity: 3, unit: 'tbsp' },
|
||||||
|
{ item_name: 'Garlic', quantity: 3, unit: 'cloves' },
|
||||||
|
{ item_name: 'Vegetable Oil', quantity: 2, unit: 'tbsp' },
|
||||||
|
{ item_name: 'Rice', quantity: 2, unit: 'cups' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Guacamole',
|
||||||
|
servings: 4,
|
||||||
|
instructions: '1. Halve and pit avocados.\n2. Scoop flesh into bowl.\n3. Mash with fork.\n4. Add lime juice, salt, onion, cilantro.\n5. Mix and adjust seasoning.',
|
||||||
|
ingredients: [
|
||||||
|
{ item_name: 'Avocado', quantity: 3, unit: 'whole' },
|
||||||
|
{ item_name: 'Lime', quantity: 1, unit: 'whole' },
|
||||||
|
{ item_name: 'Red Onion', quantity: 0.25, unit: 'whole' },
|
||||||
|
{ item_name: 'Cilantro', quantity: 2, unit: 'tbsp' },
|
||||||
|
{ item_name: 'Salt', quantity: 0.5, unit: 'tsp' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Tomato Soup',
|
||||||
|
servings: 4,
|
||||||
|
instructions: '1. Sauté onion and garlic.\n2. Add tomatoes and broth.\n3. Simmer 20 minutes.\n4. Blend until smooth.\n5. Season with salt and pepper.',
|
||||||
|
ingredients: [
|
||||||
|
{ item_name: 'Canned Tomatoes', quantity: 2, unit: 'can' },
|
||||||
|
{ item_name: 'Onion', quantity: 1, unit: 'whole' },
|
||||||
|
{ item_name: 'Garlic', quantity: 2, unit: 'cloves' },
|
||||||
|
{ item_name: 'Vegetable Broth', quantity: 2, unit: 'cups' },
|
||||||
|
{ item_name: 'Olive Oil', quantity: 2, unit: 'tbsp' },
|
||||||
|
{ item_name: 'Salt', quantity: 1, unit: 'tsp' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'French Toast',
|
||||||
|
servings: 2,
|
||||||
|
instructions: '1. Whisk eggs, milk, and cinnamon.\n2. Dip bread slices.\n3. Cook on buttered pan until golden.\n4. Serve with maple syrup.',
|
||||||
|
ingredients: [
|
||||||
|
{ item_name: 'Bread', quantity: 4, unit: 'slices' },
|
||||||
|
{ item_name: 'Eggs', quantity: 2, unit: 'pieces' },
|
||||||
|
{ item_name: 'Milk', quantity: 0.25, unit: 'cups' },
|
||||||
|
{ item_name: 'Butter', quantity: 1, unit: 'tbsp' },
|
||||||
|
{ item_name: 'Cinnamon', quantity: 0.5, unit: 'tsp' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Oatmeal',
|
||||||
|
servings: 1,
|
||||||
|
instructions: '1. Bring water or milk to boil.\n2. Add oats.\n3. Cook 5 minutes stirring occasionally.\n4. Top with fruit and honey.',
|
||||||
|
ingredients: [
|
||||||
|
{ item_name: 'Rolled Oats', quantity: 0.5, unit: 'cups' },
|
||||||
|
{ item_name: 'Milk', quantity: 1, unit: 'cups' },
|
||||||
|
{ item_name: 'Honey', quantity: 1, unit: 'tbsp' },
|
||||||
|
{ item_name: 'Salt', quantity: 1, unit: 'pinch' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function seed(knex: Knex): Promise<void> {
|
||||||
|
// Clear existing
|
||||||
|
await knex('recipe_ingredients').delete();
|
||||||
|
await knex('recipes').delete();
|
||||||
|
|
||||||
|
for (const recipe of recipes) {
|
||||||
|
const [inserted] = await knex('recipes')
|
||||||
|
.insert({
|
||||||
|
name: recipe.name,
|
||||||
|
servings: recipe.servings,
|
||||||
|
instructions: recipe.instructions,
|
||||||
|
})
|
||||||
|
.returning('id');
|
||||||
|
|
||||||
|
const recipeId = inserted.id;
|
||||||
|
|
||||||
|
await knex('recipe_ingredients').insert(
|
||||||
|
recipe.ingredients.map((ing) => ({
|
||||||
|
recipe_id: recipeId,
|
||||||
|
item_name: ing.item_name,
|
||||||
|
item_name_lower: ing.item_name.toLowerCase(),
|
||||||
|
quantity: ing.quantity,
|
||||||
|
unit: ing.unit,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const cron = require('node-cron');
|
|
||||||
const { getDb } = require('../db/knex');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hard-deletes all user accounts whose deletion_scheduled_at has passed.
|
|
||||||
* CASCADE constraints handle pantry_items, shopping_lists, and all child records.
|
|
||||||
* Runs daily at 02:00 UTC.
|
|
||||||
*/
|
|
||||||
async function hardDeleteExpiredAccounts() {
|
|
||||||
const db = getDb();
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const deleted = await db('users')
|
|
||||||
.whereNotNull('deletion_scheduled_at')
|
|
||||||
.where('deletion_scheduled_at', '<=', now)
|
|
||||||
.delete()
|
|
||||||
.returning('id');
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[CRON] Hard-deleted ${deleted.length} expired account(s) at ${now.toISOString()}`
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[CRON] Error during account hard-delete job:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startCronJobs() {
|
|
||||||
// Daily at 02:00 UTC
|
|
||||||
cron.schedule('0 2 * * *', hardDeleteExpiredAccounts, { timezone: 'UTC' });
|
|
||||||
console.log('[CRON] Account cleanup job scheduled (daily 02:00 UTC)');
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { startCronJobs, hardDeleteExpiredAccounts };
|
|
||||||
34
src/jobs/cronJobs.ts
Normal file
34
src/jobs/cronJobs.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import cron from 'node-cron';
|
||||||
|
import db from '../db/connection';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
import { syncService } from '../services/syncService';
|
||||||
|
|
||||||
|
let lastCronRun: Date | null = null;
|
||||||
|
|
||||||
|
export function getLastCronRun(): Date | null {
|
||||||
|
return lastCronRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startCronJobs(): void {
|
||||||
|
// Daily at 2:00 AM UTC — hard-delete expired accounts
|
||||||
|
cron.schedule('0 2 * * *', async () => {
|
||||||
|
logger.info('Running daily account hard-delete job...');
|
||||||
|
try {
|
||||||
|
const result = await db('users')
|
||||||
|
.where('deletion_scheduled_at', '<=', db.fn.now())
|
||||||
|
.whereNotNull('deletion_scheduled_at')
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
logger.info({ deletedCount: result }, 'Hard-delete job complete.');
|
||||||
|
|
||||||
|
// Clean up old tombstones
|
||||||
|
await syncService.cleanupTombstones();
|
||||||
|
|
||||||
|
lastCronRun = new Date();
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, 'Hard-delete cron job failed.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Cron jobs registered.');
|
||||||
|
}
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const cors = require('cors');
|
|
||||||
const { requestLogger } = require('../middleware/requestLogger');
|
|
||||||
const { errorHandler } = require('../middleware/errorHandler');
|
|
||||||
const authRoutes = require('../routes/auth');
|
|
||||||
const pantryRoutes = require('../routes/pantry');
|
|
||||||
const recipeRoutes = require('../routes/recipes');
|
|
||||||
const shoppingRoutes = require('../routes/shopping');
|
|
||||||
const syncRoutes = require('../routes/sync');
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
|
|
||||||
app.use(cors());
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(requestLogger);
|
|
||||||
|
|
||||||
// Health check — no auth required
|
|
||||||
app.get('/health', (req, res) => {
|
|
||||||
res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use('/v1/auth', authRoutes);
|
|
||||||
app.use('/v1/pantry', pantryRoutes);
|
|
||||||
app.use('/v1/recipes', recipeRoutes);
|
|
||||||
app.use('/v1/shopping-lists', shoppingRoutes);
|
|
||||||
app.use('/v1/sync', syncRoutes);
|
|
||||||
|
|
||||||
// 404 handler for unmatched routes
|
|
||||||
app.use((req, res) => {
|
|
||||||
res.status(404).json({
|
|
||||||
error: 'Route not found.',
|
|
||||||
code: 'NOT_FOUND',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use(errorHandler);
|
|
||||||
|
|
||||||
module.exports = app;
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
require('dotenv').config();
|
|
||||||
const app = require('./app');
|
|
||||||
const { startCronJobs } = require('../jobs/cleanup');
|
|
||||||
const config = require('../config');
|
|
||||||
|
|
||||||
const PORT = config.port;
|
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
|
||||||
console.log(`[SERVER] Pantree API listening on port ${PORT} (${config.nodeEnv})`);
|
|
||||||
startCronJobs();
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = app;
|
|
||||||
52
src/middleware/auth.ts
Normal file
52
src/middleware/auth.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { config } from '../config/env';
|
||||||
|
import { createError } from './errorHandler';
|
||||||
|
import db from '../db/connection';
|
||||||
|
|
||||||
|
export interface AuthenticatedRequest extends Request {
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authMiddleware(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
_res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return next(createError('Missing or invalid Authorization header.', 401, 'UNAUTHORIZED'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
let payload: { userId: string };
|
||||||
|
|
||||||
|
try {
|
||||||
|
payload = jwt.verify(token, config.jwtSecret) as { userId: string };
|
||||||
|
} catch {
|
||||||
|
return next(createError('Invalid or expired token.', 401, 'UNAUTHORIZED'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user still exists and is not hard-deleted
|
||||||
|
const user = await db('users')
|
||||||
|
.where({ id: payload.userId })
|
||||||
|
.whereNull('deletion_scheduled_at')
|
||||||
|
.select('id', 'deleted_at', 'deletion_scheduled_at')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return next(createError('Invalid or expired token.', 401, 'UNAUTHORIZED'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block access for soft-deleted accounts (except restore endpoint)
|
||||||
|
if (user.deleted_at && !req.path.includes('/restore-account')) {
|
||||||
|
return next(createError('Account is pending deletion.', 403, 'FORBIDDEN'));
|
||||||
|
}
|
||||||
|
|
||||||
|
req.userId = payload.userId;
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const jwt = require('jsonwebtoken');
|
|
||||||
const config = require('../config');
|
|
||||||
const { AppError } = require('../utils/errors');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JWT authentication middleware.
|
|
||||||
* Validates the Bearer token on every protected route.
|
|
||||||
* Attaches decoded payload to req.user.
|
|
||||||
* The token is not trusted until verified — no exceptions.
|
|
||||||
*/
|
|
||||||
function authenticate(req, res, next) {
|
|
||||||
const authHeader = req.headers['authorization'];
|
|
||||||
|
|
||||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
||||||
return next(new AppError(401, 'UNAUTHORIZED', 'Missing or malformed Authorization header.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = authHeader.slice(7).trim();
|
|
||||||
if (!token) {
|
|
||||||
return next(new AppError(401, 'UNAUTHORIZED', 'Missing token.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload;
|
|
||||||
try {
|
|
||||||
payload = jwt.verify(token, config.jwt.secret);
|
|
||||||
} catch (err) {
|
|
||||||
return next(new AppError(401, 'UNAUTHORIZED', 'Invalid or expired token.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!payload.sub) {
|
|
||||||
return next(new AppError(401, 'UNAUTHORIZED', 'Malformed token payload.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
req.user = payload;
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { authenticate };
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Central error handler.
|
|
||||||
* All errors thrown in route handlers or services arrive here.
|
|
||||||
* Known AppErrors are mapped to their HTTP status.
|
|
||||||
* Everything else is a 500.
|
|
||||||
*/
|
|
||||||
function errorHandler(err, req, res, next) { // eslint-disable-line no-unused-vars
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
|
|
||||||
// Structured application error
|
|
||||||
if (err.isAppError) {
|
|
||||||
return res.status(err.status).json({
|
|
||||||
error: err.message,
|
|
||||||
code: err.code,
|
|
||||||
...(err.extra || {}),
|
|
||||||
timestamp,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zod validation error (surfaced via validate() helper)
|
|
||||||
if (err.zodError) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: err.message || 'Validation failed.',
|
|
||||||
code: 'VALIDATION_ERROR',
|
|
||||||
details: err.issues,
|
|
||||||
timestamp,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unexpected error — log it, return generic 500
|
|
||||||
console.error(`[ERROR] [${req.requestId || '-'}] ${err.stack || err.message}`);
|
|
||||||
|
|
||||||
return res.status(500).json({
|
|
||||||
error: 'An unexpected error occurred.',
|
|
||||||
code: 'INTERNAL_ERROR',
|
|
||||||
timestamp,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { errorHandler };
|
|
||||||
47
src/middleware/errorHandler.ts
Normal file
47
src/middleware/errorHandler.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { ZodError } from 'zod';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
export interface AppError extends Error {
|
||||||
|
statusCode?: number;
|
||||||
|
code?: string;
|
||||||
|
details?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createError(message: string, statusCode: number, code: string): AppError {
|
||||||
|
const err: AppError = new Error(message);
|
||||||
|
err.statusCode = statusCode;
|
||||||
|
err.code = code;
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorHandler(
|
||||||
|
err: AppError,
|
||||||
|
_req: Request,
|
||||||
|
res: Response,
|
||||||
|
_next: NextFunction
|
||||||
|
): void {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
|
||||||
|
if (err instanceof ZodError) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: err.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join('; '),
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusCode = err.statusCode ?? 500;
|
||||||
|
const code = err.code ?? 'INTERNAL_ERROR';
|
||||||
|
|
||||||
|
if (statusCode >= 500) {
|
||||||
|
logger.error({ err }, 'Unhandled server error');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(statusCode).json({
|
||||||
|
error: statusCode >= 500 ? 'An internal error occurred.' : err.message,
|
||||||
|
code,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const { v4: uuidv4 } = require('uuid');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attaches a unique request ID to every inbound request.
|
|
||||||
* Logs method + path for basic observability.
|
|
||||||
*/
|
|
||||||
function requestLogger(req, res, next) {
|
|
||||||
req.requestId = uuidv4();
|
|
||||||
res.setHeader('X-Request-Id', req.requestId);
|
|
||||||
console.log(`[${new Date().toISOString()}] [${req.requestId}] ${req.method} ${req.path}`);
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { requestLogger };
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const router = express.Router();
|
|
||||||
const authService = require('../services/authService');
|
|
||||||
const { authenticate } = require('../middleware/authenticate');
|
|
||||||
const { validate } = require('../utils/validate');
|
|
||||||
const {
|
|
||||||
signupSchema,
|
|
||||||
signinSchema,
|
|
||||||
googleAuthSchema,
|
|
||||||
passwordResetRequestSchema,
|
|
||||||
passwordResetConfirmSchema,
|
|
||||||
restoreAccountSchema,
|
|
||||||
} = require('../utils/validation');
|
|
||||||
|
|
||||||
// POST /v1/auth/signup
|
|
||||||
router.post('/signup', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const data = validate(signupSchema, req.body);
|
|
||||||
const result = await authService.signup(data);
|
|
||||||
return res.status(201).json(result);
|
|
||||||
} catch (err) {
|
|
||||||
return next(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /v1/auth/signin
|
|
||||||
router.post('/signin', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const data = validate(signinSchema, req.body);
|
|
||||||
const result = await authService.signin(data);
|
|
||||||
return res.status(200).json(result);
|
|
||||||
} catch (err) {
|
|
||||||
return next(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /v1/auth/google
|
|
||||||
router.post('/google', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const data = validate(googleAuthSchema, req.body);
|
|
||||||
const result = await authService.googleAuth(data);
|
|
||||||
const status = result.is_new_user ? 201 : 200;
|
|
||||||
// Strip internal flag from response
|
|
||||||
const { is_new_user, ...response } = result; // eslint-disable-line no-unused-vars
|
|
||||||
return res.status(status).json(response);
|
|
||||||
} catch (err) {
|
|
||||||
return next(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /v1/auth/password-reset — request reset email
|
|
||||||
router.post('/password-reset', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const data = validate(passwordResetRequestSchema, req.body);
|
|
||||||
await authService.requestPasswordReset(data);
|
|
||||||
// Always 200 — never reveal whether email exists
|
|
||||||
return res.status(200).json({
|
|
||||||
message: 'If an account exists with this email, a reset link has been sent.',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
return next(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// PUT /v1/auth/password-reset — complete reset with token
|
|
||||||
router.put('/password-reset', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const data = validate(passwordResetConfirmSchema, req.body);
|
|
||||||
await authService.confirmPasswordReset(data);
|
|
||||||
return res.status(200).json({
|
|
||||||
message: 'Password updated successfully.',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
return next(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// DELETE /v1/auth/account — soft-delete authenticated user's account
|
|
||||||
router.delete('/account', authenticate, async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
await authService.deleteAccount(req.user.sub);
|
|
||||||
return res.status(204).send();
|
|
||||||
} catch (err) {
|
|
||||||
return next(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /v1/auth/restore-account — restore soft-deleted account
|
|
||||||
router.post('/restore-account', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const data = validate(restoreAccountSchema, req.body);
|
|
||||||
const result = await authService.restoreAccount(data);
|
|
||||||
return res.status(200).json(result);
|
|
||||||
} catch (err) {
|
|
||||||
return next(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
134
src/routes/auth.ts
Normal file
134
src/routes/auth.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { Router, Response, NextFunction } from 'express';
|
||||||
|
import { authService } from '../services/authService';
|
||||||
|
import { AuthenticatedRequest, authMiddleware } from '../middleware/auth';
|
||||||
|
import {
|
||||||
|
signupSchema,
|
||||||
|
signinSchema,
|
||||||
|
googleAuthSchema,
|
||||||
|
passwordResetRequestSchema,
|
||||||
|
passwordResetConfirmSchema,
|
||||||
|
} from '../utils/validators';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// POST /auth/signup
|
||||||
|
router.post('/signup', async (req, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { email, password, name } = signupSchema.parse(req.body);
|
||||||
|
const result = await authService.signup(email, password, name);
|
||||||
|
res.status(201).json(result);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /auth/signin
|
||||||
|
router.post('/signin', async (req, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { email, password } = signinSchema.parse(req.body);
|
||||||
|
const result = await authService.signin(email, password);
|
||||||
|
res.status(200).json(result);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (
|
||||||
|
err instanceof Error &&
|
||||||
|
(err as { code?: string }).code === 'ACCOUNT_PENDING_DELETION'
|
||||||
|
) {
|
||||||
|
const appErr = err as { code?: string; statusCode?: number; deletion_scheduled_at?: string };
|
||||||
|
res.status(403).json({
|
||||||
|
error: err.message,
|
||||||
|
code: appErr.code,
|
||||||
|
deletion_scheduled_at: appErr.deletion_scheduled_at,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /auth/google
|
||||||
|
router.post('/google', async (req, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { id_token } = googleAuthSchema.parse(req.body);
|
||||||
|
const result = await authService.googleAuth(id_token);
|
||||||
|
const statusCode = result.is_new_user ? 201 : 200;
|
||||||
|
res.status(statusCode).json(result);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (
|
||||||
|
err instanceof Error &&
|
||||||
|
(err as { code?: string }).code === 'ACCOUNT_PENDING_DELETION'
|
||||||
|
) {
|
||||||
|
const appErr = err as { code?: string; statusCode?: number; deletion_scheduled_at?: string };
|
||||||
|
res.status(403).json({
|
||||||
|
error: err.message,
|
||||||
|
code: appErr.code,
|
||||||
|
deletion_scheduled_at: appErr.deletion_scheduled_at,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /auth/password-reset
|
||||||
|
router.post('/password-reset', async (req, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { email } = passwordResetRequestSchema.parse(req.body);
|
||||||
|
await authService.requestPasswordReset(email);
|
||||||
|
res.status(200).json({
|
||||||
|
message: 'If an account exists with this email, a reset link has been sent.',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /auth/password-reset
|
||||||
|
router.put('/password-reset', async (req, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { token, new_password } = passwordResetConfirmSchema.parse(req.body);
|
||||||
|
await authService.confirmPasswordReset(token, new_password);
|
||||||
|
res.status(200).json({
|
||||||
|
message: 'Password updated successfully.',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /auth/account (protected)
|
||||||
|
router.delete(
|
||||||
|
'/account',
|
||||||
|
authMiddleware,
|
||||||
|
async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
await authService.deleteAccount(req.userId!);
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /auth/restore-account (protected)
|
||||||
|
router.post(
|
||||||
|
'/restore-account',
|
||||||
|
authMiddleware,
|
||||||
|
async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const user = await authService.restoreAccount(req.userId!);
|
||||||
|
res.status(200).json({
|
||||||
|
user,
|
||||||
|
message: 'Account restored successfully.',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const router = express.Router();
|
|
||||||
const pantryService = require('../services/pantryService');
|
|
||||||
const { authenticate } = require('../middleware/authenticate');
|
|
||||||
const { validate } = require('../utils/validate');
|
|
||||||
const { addPantryItemSchema, updatePantryItemSchema } = require('../utils/validation');
|
|
||||||
|
|
||||||
// All pantry routes require authentication
|
|
||||||
router.use(authenticate);
|
|
||||||
|
|
||||||
// GET /v1/pantry
|
|
||||||
router.get('/', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const items = await pantryService.getPantryItems(req.user.sub);
|
|
||||||
return res.status(200).json({
|
|
||||||
items,
|
|
||||||
synced_at: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
return next(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /v1/pantry
|
|
||||||
router.post('/', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const data = validate(addPantryItemSchema, req.body);
|
|
||||||
const item = await pantryService.addPantryItem(req.user.sub, data);
|
|
||||||
return res.status(201).json({ item });
|
|
||||||
} catch (err) {
|
|
||||||
return next(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// PUT /v1/pantry/:item_id
|
|
||||||
router.put('/:item_id', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const data = validate(updatePantryItemSchema, req.body);
|
|
||||||
const item = await pantryService.updatePantryItem(req.user.sub, req.params.item_id, data);
|
|
||||||
return res.status(200).json({ item });
|
|
||||||
} catch (err) {
|
|
||||||
return next(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// DELETE /v1/pantry/:item_id
|
|
||||||
router.delete('/:item_id', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
await pantryService.deletePantryItem(req.user.sub, req.params.item_id);
|
|
||||||
return res.status(204).send();
|
|
||||||
} catch (err) {
|
|
||||||
return next(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
51
src/routes/pantry.ts
Normal file
51
src/routes/pantry.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Router, Response, NextFunction } from 'express';
|
||||||
|
import { authMiddleware, AuthenticatedRequest } from '../middleware/auth';
|
||||||
|
import { pantryService } from '../services/pantryService';
|
||||||
|
import { addPantryItemSchema, updatePantryItemSchema } from '../utils/validators';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
// GET /pantry
|
||||||
|
router.get('/', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const items = await pantryService.getItems(req.userId!);
|
||||||
|
res.status(200).json({ items, synced_at: new Date().toISOString() });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /pantry
|
||||||
|
router.post('/', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { item_name, quantity } = addPantryItemSchema.parse(req.body);
|
||||||
|
const item = await pantryService.addItem(req.userId!, item_name, quantity);
|
||||||
|
res.status(201).json({ item, synced_at: new Date().toISOString() });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /pantry/:item_id
|
||||||
|
router.put('/:item_id', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { quantity } = updatePantryItemSchema.parse(req.body);
|
||||||
|
const item = await pantryService.updateItem(req.userId!, req.params.item_id, quantity);
|
||||||
|
res.status(200).json({ item, synced_at: new Date().toISOString() });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /pantry/:item_id
|
||||||
|
router.delete('/:item_id', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
await pantryService.deleteItem(req.userId!, req.params.item_id);
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const router = express.Router();
|
|
||||||
const recipeService = require('../services/recipeService');
|
|
||||||
const { authenticate } = require('../middleware/authenticate');
|
|
||||||
const { validate } = require('../utils/validate');
|
|
||||||
const { recipeQuerySchema } = require('../utils/validation');
|
|
||||||
|
|
||||||
// All recipe routes require authentication
|
|
||||||
router.use(authenticate);
|
|
||||||
|
|
||||||
// GET /v1/recipes?filter=all|available|partial&scale=1|2|3
|
|
||||||
router.get('/', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const query = validate(recipeQuerySchema, req.query);
|
|
||||||
const recipes = await recipeService.getRecipes(req.user.sub, query);
|
|
||||||
return res.status(200).json({
|
|
||||||
recipes,
|
|
||||||
synced_at: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
return next(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /v1/recipes/:recipe_id?scale=1|2|3
|
|
||||||
router.get('/:recipe_id', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const query = validate(recipeQuerySchema, req.query);
|
|
||||||
const recipe = await recipeService.getRecipeById(
|
|
||||||
req.user.sub,
|
|
||||||
req.params.recipe_id,
|
|
||||||
query
|
|
||||||
);
|
|
||||||
return res.status(200).json({ recipe });
|
|
||||||
} catch (err) {
|
|
||||||
return next(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
31
src/routes/recipes.ts
Normal file
31
src/routes/recipes.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Router, Response, NextFunction } from 'express';
|
||||||
|
import { authMiddleware, AuthenticatedRequest } from '../middleware/auth';
|
||||||
|
import { recipeService } from '../services/recipeService';
|
||||||
|
import { recipeQuerySchema, recipeDetailQuerySchema } from '../utils/validators';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
// GET /recipes
|
||||||
|
router.get('/', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { filter, page, limit, search } = recipeQuerySchema.parse(req.query);
|
||||||
|
const result = await recipeService.getRecipes(req.userId!, filter, page, limit, search);
|
||||||
|
res.status(200).json({ ...result, synced_at: new Date().toISOString() });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /recipes/:recipe_id
|
||||||
|
router.get('/:recipe_id', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { scale } = recipeDetailQuerySchema.parse(req.query);
|
||||||
|
const recipe = await recipeService.getRecipeById(req.params.recipe_id, req.userId!, scale);
|
||||||
|
res.status(200).json({ recipe });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const router = express.Router();
|
|
||||||
const shoppingService = require('../services/shoppingService');
|
|
||||||
const { authenticate } = require('../middleware/authenticate');
|
|
||||||
const { validate } = require('../utils/validate');
|
|
||||||
const {
|
|
||||||
createShoppingListSchema,
|
|
||||||
addShoppingListItemSchema,
|
|
||||||
updateShoppingListItemSchema,
|
|
||||||
addRecipesToListSchema,
|
|
||||||
} = require('../utils/validation');
|
|
||||||
|
|
||||||
// All shopping list routes require authentication
|
|
||||||
router.use(authenticate);
|
|
||||||
|
|
||||||
// GET /v1/shopping-lists
|
|
||||||
router.get('/', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const lists = await shoppingService.getShoppingLists(req.user.sub);
|
|
||||||
return res.status(200).json({
|
|
||||||
shopping_lists: lists,
|
|
||||||
synced_at: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
return next(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /v1/shopping-lists
|
|
||||||
router.post('/', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const data = validate(createShoppingListSchema, req.body);
|
|
||||||
const list = await shoppingService.createShoppingList(req.user.sub, data);
|
|
||||||
return res.status(201).json({ shopping_list: list });
|
|
||||||
} catch (err) {
|
|
||||||
return next(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /v1/shopping-lists/:list_id
|
|
||||||
router.get('/:list_id', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const list = await shoppingService.getShoppingListById(
|
|
||||||
req.user.sub,
|
|
||||||
req.params.list_id
|
|
||||||
);
|
|
||||||
return res.status(200).json({
|
|
||||||
shopping_list: list,
|
|
||||||
synced_at: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
return next(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// DELETE /v1/shopping-lists/:list_id
|
|
||||||
router.delete('/:list_id', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
await shoppingService.deleteShoppingList(req.user.sub, req.params.list_id);
|
|
||||||
return res.status(204).send();
|
|
||||||
} catch (err) {
|
|
||||||
return next(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /v1/shopping-lists/:list_id/items
|
|
||||||
router.post('/:list_id/items', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const data = validate(addShoppingListItemSchema, req.body);
|
|
||||||
const result = await shoppingService.addItemToList(
|
|
||||||
req.user.sub,
|
|
||||||
req.params.list_id,
|
|
||||||
data
|
|
||||||
);
|
|
||||||
return res.status(201).json(result);
|
|
||||||
} catch (err) {
|
|
||||||
return next(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /v1/shopping-lists/:list_id/add-recipes
|
|
||||||
router.post('/:list_id/add-recipes', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const data = validate(addRecipesToListSchema, req.body);
|
|
||||||
const result = await shoppingService.addRecipesToList(
|
|
||||||
req.user.sub,
|
|
||||||
req.params.list_id,
|
|
||||||
data
|
|
||||||
);
|
|
||||||
return res.status(201).json(result);
|
|
||||||
} catch (err) {
|
|
||||||
return next(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// PUT /v1/shopping-lists/:list_id/items/:item_id
|
|
||||||
router.put('/:list_id/items/:item_id', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const data = validate(updateShoppingListItemSchema, req.body);
|
|
||||||
const item = await shoppingService.updateListItem(
|
|
||||||
req.user.sub,
|
|
||||||
req.params.list_id,
|
|
||||||
req.params.item_id,
|
|
||||||
data
|
|
||||||
);
|
|
||||||
return res.status(200).json({ item });
|
|
||||||
} catch (err) {
|
|
||||||
return next(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// DELETE /v1/shopping-lists/:list_id/items/:item_id
|
|
||||||
router.delete('/:list_id/items/:item_id', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
await shoppingService.deleteListItem(
|
|
||||||
req.user.sub,
|
|
||||||
req.params.list_id,
|
|
||||||
req.params.item_id
|
|
||||||
);
|
|
||||||
return res.status(204).send();
|
|
||||||
} catch (err) {
|
|
||||||
return next(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
118
src/routes/shoppingLists.ts
Normal file
118
src/routes/shoppingLists.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { Router, Response, NextFunction } from 'express';
|
||||||
|
import { authMiddleware, AuthenticatedRequest } from '../middleware/auth';
|
||||||
|
import { shoppingListService } from '../services/shoppingListService';
|
||||||
|
import {
|
||||||
|
createShoppingListSchema,
|
||||||
|
addShoppingListItemSchema,
|
||||||
|
updateShoppingListItemSchema,
|
||||||
|
addRecipesToListSchema,
|
||||||
|
} from '../utils/validators';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
// GET /shopping-lists
|
||||||
|
router.get('/', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const lists = await shoppingListService.getLists(req.userId!);
|
||||||
|
res.status(200).json({ shopping_lists: lists, synced_at: new Date().toISOString() });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /shopping-lists
|
||||||
|
router.post('/', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { list_name } = createShoppingListSchema.parse(req.body);
|
||||||
|
const list = await shoppingListService.createList(req.userId!, list_name);
|
||||||
|
res.status(201).json({ shopping_list: list });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /shopping-lists/:list_id
|
||||||
|
router.get('/:list_id', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const list = await shoppingListService.getListById(req.params.list_id, req.userId!);
|
||||||
|
res.status(200).json({ shopping_list: list, synced_at: new Date().toISOString() });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /shopping-lists/:list_id
|
||||||
|
router.delete('/:list_id', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
await shoppingListService.deleteList(req.params.list_id, req.userId!);
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /shopping-lists/:list_id/items
|
||||||
|
router.post('/:list_id/items', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { item_name, quantity, unit } = addShoppingListItemSchema.parse(req.body);
|
||||||
|
const result = await shoppingListService.addItem(
|
||||||
|
req.params.list_id,
|
||||||
|
req.userId!,
|
||||||
|
item_name,
|
||||||
|
quantity,
|
||||||
|
unit
|
||||||
|
);
|
||||||
|
res.status(201).json(result);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /shopping-lists/:list_id/add-recipes
|
||||||
|
router.post('/:list_id/add-recipes', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { recipe_ids, scale_factor } = addRecipesToListSchema.parse(req.body);
|
||||||
|
const result = await shoppingListService.addRecipesToList(
|
||||||
|
req.params.list_id,
|
||||||
|
req.userId!,
|
||||||
|
recipe_ids,
|
||||||
|
scale_factor
|
||||||
|
);
|
||||||
|
res.status(201).json(result);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /shopping-lists/:list_id/items/:item_id
|
||||||
|
router.put('/:list_id/items/:item_id', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const updates = updateShoppingListItemSchema.parse(req.body);
|
||||||
|
const item = await shoppingListService.updateItem(
|
||||||
|
req.params.list_id,
|
||||||
|
req.params.item_id,
|
||||||
|
req.userId!,
|
||||||
|
updates
|
||||||
|
);
|
||||||
|
res.status(200).json({ item, synced_at: new Date().toISOString() });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /shopping-lists/:list_id/items/:item_id
|
||||||
|
router.delete('/:list_id/items/:item_id', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
await shoppingListService.deleteItem(
|
||||||
|
req.params.list_id,
|
||||||
|
req.params.item_id,
|
||||||
|
req.userId!
|
||||||
|
);
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const router = express.Router();
|
|
||||||
const syncService = require('../services/syncService');
|
|
||||||
const { authenticate } = require('../middleware/authenticate');
|
|
||||||
const { validate } = require('../utils/validate');
|
|
||||||
const { syncQuerySchema } = require('../utils/validation');
|
|
||||||
|
|
||||||
router.use(authenticate);
|
|
||||||
|
|
||||||
// GET /v1/sync?since=<ISO8601>
|
|
||||||
router.get('/', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const query = validate(syncQuerySchema, req.query);
|
|
||||||
const data = await syncService.getSyncData(req.user.sub, query.since || null);
|
|
||||||
return res.status(200).json(data);
|
|
||||||
} catch (err) {
|
|
||||||
return next(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
20
src/routes/sync.ts
Normal file
20
src/routes/sync.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Router, Response, NextFunction } from 'express';
|
||||||
|
import { authMiddleware, AuthenticatedRequest } from '../middleware/auth';
|
||||||
|
import { syncService } from '../services/syncService';
|
||||||
|
import { syncQuerySchema } from '../utils/validators';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
// GET /sync
|
||||||
|
router.get('/', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { since } = syncQuerySchema.parse(req.query);
|
||||||
|
const delta = await syncService.getDelta(req.userId!, since);
|
||||||
|
res.status(200).json(delta);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
24
src/server.ts
Normal file
24
src/server.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { createApp } from './app';
|
||||||
|
import { config } from './config/env';
|
||||||
|
import { logger } from './utils/logger';
|
||||||
|
import { startCronJobs } from './jobs/cronJobs';
|
||||||
|
import db from './db/connection';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// Verify DB connection
|
||||||
|
await db.raw('SELECT 1');
|
||||||
|
logger.info('Database connection established.');
|
||||||
|
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
app.listen(config.port, () => {
|
||||||
|
logger.info(`Pantree API running on port ${config.port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
startCronJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
logger.error({ err }, 'Failed to start server');
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const bcrypt = require('bcrypt');
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const { getDb } = require('../db/knex');
|
|
||||||
const { AppError } = require('../utils/errors');
|
|
||||||
const { issueToken } = require('../utils/jwt');
|
|
||||||
const config = require('../config');
|
|
||||||
|
|
||||||
const SALT_ROUNDS = 12;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a user row for API responses.
|
|
||||||
* Never exposes password_hash, google_id, or internal fields.
|
|
||||||
*/
|
|
||||||
function formatUser(row) {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
email: row.email,
|
|
||||||
name: row.name,
|
|
||||||
profile_picture_url: row.profile_picture_url || null,
|
|
||||||
email_verified: row.email_verified || false,
|
|
||||||
deleted_at: row.deleted_at || null,
|
|
||||||
created_at: row.created_at,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a new user with email + password.
|
|
||||||
* Rejects duplicate emails (case-insensitive).
|
|
||||||
*/
|
|
||||||
async function signup({ email, password, name }) {
|
|
||||||
const db = getDb();
|
|
||||||
const normalizedEmail = email.toLowerCase().trim();
|
|
||||||
|
|
||||||
const existing = await db('users')
|
|
||||||
.whereRaw('LOWER(email) = ?', [normalizedEmail])
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
throw new AppError(409, 'CONFLICT', 'Email already registered.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const password_hash = await bcrypt.hash(password, SALT_ROUNDS);
|
|
||||||
|
|
||||||
const [user] = await db('users')
|
|
||||||
.insert({
|
|
||||||
email: normalizedEmail,
|
|
||||||
password_hash,
|
|
||||||
name: name.trim(),
|
|
||||||
email_verified: false,
|
|
||||||
})
|
|
||||||
.returning('*');
|
|
||||||
|
|
||||||
const { token, expires_at } = issueToken(user);
|
|
||||||
return { user: formatUser(user), token, expires_at };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sign in with email + password.
|
|
||||||
* Returns 401 for any credential failure — no enumeration.
|
|
||||||
* Returns 403 if account is soft-deleted within the 15-day window.
|
|
||||||
*/
|
|
||||||
async function signin({ email, password }) {
|
|
||||||
const db = getDb();
|
|
||||||
const normalizedEmail = email.toLowerCase().trim();
|
|
||||||
|
|
||||||
const user = await db('users')
|
|
||||||
.whereRaw('LOWER(email) = ?', [normalizedEmail])
|
|
||||||
.first();
|
|
||||||
|
|
||||||
// Constant-time path: always attempt bcrypt compare to prevent timing attacks
|
|
||||||
const dummyHash = '$2b$12$invalidhashfortimingprotection000000000000000000000000';
|
|
||||||
const hashToCompare = user && user.password_hash ? user.password_hash : dummyHash;
|
|
||||||
const valid = await bcrypt.compare(password, hashToCompare);
|
|
||||||
|
|
||||||
if (!user || !user.password_hash || !valid) {
|
|
||||||
throw new AppError(401, 'UNAUTHORIZED', 'Invalid email or password.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.deleted_at) {
|
|
||||||
// Within 15-day window — allow sign-in but signal pending deletion
|
|
||||||
if (user.deletion_scheduled_at && new Date(user.deletion_scheduled_at) > new Date()) {
|
|
||||||
throw new AppError(403, 'ACCOUNT_PENDING_DELETION', 'Account is pending deletion.', {
|
|
||||||
deletion_scheduled_at: user.deletion_scheduled_at,
|
|
||||||
can_restore: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Past window — should have been hard-deleted by cron, but guard anyway
|
|
||||||
throw new AppError(401, 'UNAUTHORIZED', 'Invalid email or password.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { token, expires_at } = issueToken(user);
|
|
||||||
return { user: formatUser(user), token, expires_at };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sign in or register via Google ID token.
|
|
||||||
* Decodes the JWT payload — production should verify with Google's tokeninfo endpoint.
|
|
||||||
*/
|
|
||||||
async function googleAuth({ id_token }) {
|
|
||||||
let payload;
|
|
||||||
try {
|
|
||||||
const parts = id_token.split('.');
|
|
||||||
if (parts.length !== 3) throw new Error('Malformed JWT');
|
|
||||||
// Add padding for base64url decode
|
|
||||||
const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
||||||
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
|
|
||||||
payload = JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
|
|
||||||
} catch {
|
|
||||||
throw new AppError(401, 'INVALID_TOKEN', 'Google token verification failed.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!payload.sub || !payload.email) {
|
|
||||||
throw new AppError(401, 'INVALID_TOKEN', 'Google token verification failed.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
let user = await db('users').where({ google_id: payload.sub }).first();
|
|
||||||
let is_new_user = false;
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
// Check if email already exists — link accounts
|
|
||||||
user = await db('users')
|
|
||||||
.whereRaw('LOWER(email) = ?', [payload.email.toLowerCase()])
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (user) {
|
|
||||||
const [updated] = await db('users')
|
|
||||||
.where({ id: user.id })
|
|
||||||
.update({
|
|
||||||
google_id: payload.sub,
|
|
||||||
profile_picture_url: payload.picture || user.profile_picture_url,
|
|
||||||
})
|
|
||||||
.returning('*');
|
|
||||||
user = updated;
|
|
||||||
} else {
|
|
||||||
is_new_user = true;
|
|
||||||
const [created] = await db('users')
|
|
||||||
.insert({
|
|
||||||
email: payload.email.toLowerCase(),
|
|
||||||
name: payload.name || payload.email,
|
|
||||||
profile_picture_url: payload.picture || null,
|
|
||||||
google_id: payload.sub,
|
|
||||||
email_verified: true, // Google accounts are pre-verified
|
|
||||||
})
|
|
||||||
.returning('*');
|
|
||||||
user = created;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { token, expires_at } = issueToken(user);
|
|
||||||
return { user: formatUser(user), token, expires_at, is_new_user };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request a password reset email.
|
|
||||||
* Always returns success — never reveals whether the email exists.
|
|
||||||
*/
|
|
||||||
async function requestPasswordReset({ email }) {
|
|
||||||
const db = getDb();
|
|
||||||
const normalizedEmail = email.toLowerCase().trim();
|
|
||||||
const user = await db('users')
|
|
||||||
.whereRaw('LOWER(email) = ?', [normalizedEmail])
|
|
||||||
.whereNull('deleted_at')
|
|
||||||
.first();
|
|
||||||
|
|
||||||
// Silent return — no enumeration
|
|
||||||
if (!user || !user.password_hash) return;
|
|
||||||
|
|
||||||
// Generate a cryptographically random token
|
|
||||||
const rawToken = crypto.randomBytes(32).toString('hex');
|
|
||||||
const token_hash = crypto.createHash('sha256').update(rawToken).digest('hex');
|
|
||||||
const expires_at = new Date(Date.now() + config.resetTokenExpiryHours * 60 * 60 * 1000);
|
|
||||||
|
|
||||||
await db('password_reset_tokens').insert({
|
|
||||||
user_id: user.id,
|
|
||||||
token_hash,
|
|
||||||
expires_at,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Production: send email with link containing rawToken
|
|
||||||
// await emailService.sendPasswordReset(user.email, rawToken);
|
|
||||||
console.log(`[PASSWORD RESET] raw_token=${rawToken} user_id=${user.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Complete a password reset using the token from the email link.
|
|
||||||
* Token is SHA-256 hashed before DB lookup — raw token never stored.
|
|
||||||
*/
|
|
||||||
async function confirmPasswordReset({ token, new_password }) {
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
const token_hash = crypto.createHash('sha256').update(token).digest('hex');
|
|
||||||
const record = await db('password_reset_tokens').where({ token_hash }).first();
|
|
||||||
|
|
||||||
if (!record || record.used_at) {
|
|
||||||
throw new AppError(401, 'INVALID_TOKEN', 'Token is invalid or has already been used.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (new Date(record.expires_at) < new Date()) {
|
|
||||||
throw new AppError(401, 'INVALID_TOKEN', 'Token has expired.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const password_hash = await bcrypt.hash(new_password, SALT_ROUNDS);
|
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
|
||||||
await trx('users').where({ id: record.user_id }).update({ password_hash });
|
|
||||||
await trx('password_reset_tokens')
|
|
||||||
.where({ id: record.id })
|
|
||||||
.update({ used_at: new Date() });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Soft-delete a user account.
|
|
||||||
* Sets deleted_at and deletion_scheduled_at (now + 15 days).
|
|
||||||
* Hard delete is handled by the daily cron job.
|
|
||||||
*/
|
|
||||||
async function deleteAccount(userId) {
|
|
||||||
const db = getDb();
|
|
||||||
const now = new Date();
|
|
||||||
const deletion_scheduled_at = new Date(
|
|
||||||
now.getTime() + config.accountDeletionDays * 24 * 60 * 60 * 1000
|
|
||||||
);
|
|
||||||
|
|
||||||
await db('users').where({ id: userId }).update({
|
|
||||||
deleted_at: now,
|
|
||||||
deletion_scheduled_at,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restore a soft-deleted account within the 15-day window.
|
|
||||||
* Requires valid credentials — the token from the 403 signin response is used.
|
|
||||||
*/
|
|
||||||
async function restoreAccount({ email, password }) {
|
|
||||||
const db = getDb();
|
|
||||||
const normalizedEmail = email.toLowerCase().trim();
|
|
||||||
|
|
||||||
const user = await db('users')
|
|
||||||
.whereRaw('LOWER(email) = ?', [normalizedEmail])
|
|
||||||
.first();
|
|
||||||
|
|
||||||
const dummyHash = '$2b$12$invalidhashfortimingprotection000000000000000000000000';
|
|
||||||
const hashToCompare = user && user.password_hash ? user.password_hash : dummyHash;
|
|
||||||
const valid = await bcrypt.compare(password, hashToCompare);
|
|
||||||
|
|
||||||
if (!user || !user.password_hash || !valid) {
|
|
||||||
throw new AppError(401, 'UNAUTHORIZED', 'Invalid credentials.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Past the 15-day window
|
|
||||||
if (
|
|
||||||
user.deletion_scheduled_at &&
|
|
||||||
new Date(user.deletion_scheduled_at) <= new Date()
|
|
||||||
) {
|
|
||||||
throw new AppError(410, 'GONE', 'Account has been permanently deleted.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Account was never deleted
|
|
||||||
if (!user.deleted_at) {
|
|
||||||
const { token, expires_at } = issueToken(user);
|
|
||||||
return {
|
|
||||||
user: formatUser(user),
|
|
||||||
token,
|
|
||||||
expires_at,
|
|
||||||
message: 'Account is active.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const [restored] = await db('users')
|
|
||||||
.where({ id: user.id })
|
|
||||||
.update({ deleted_at: null, deletion_scheduled_at: null })
|
|
||||||
.returning('*');
|
|
||||||
|
|
||||||
const { token, expires_at } = issueToken(restored);
|
|
||||||
return {
|
|
||||||
user: formatUser(restored),
|
|
||||||
token,
|
|
||||||
expires_at,
|
|
||||||
message: 'Account restored successfully.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
signup,
|
|
||||||
signin,
|
|
||||||
googleAuth,
|
|
||||||
requestPasswordReset,
|
|
||||||
confirmPasswordReset,
|
|
||||||
deleteAccount,
|
|
||||||
restoreAccount,
|
|
||||||
};
|
|
||||||
254
src/services/authService.ts
Normal file
254
src/services/authService.ts
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { OAuth2Client } from 'google-auth-library';
|
||||||
|
import db from '../db/connection';
|
||||||
|
import { config } from '../config/env';
|
||||||
|
import { signToken } from '../utils/jwt';
|
||||||
|
import { createError } from '../middleware/errorHandler';
|
||||||
|
import { BCRYPT_ROUNDS, ACCOUNT_DELETION_DAYS, PASSWORD_RESET_EXPIRES_HOURS } from '../config/constants';
|
||||||
|
import { emailService } from './emailService';
|
||||||
|
|
||||||
|
const googleClient = new OAuth2Client(config.googleClientId);
|
||||||
|
|
||||||
|
function formatUser(user: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
profile_picture_url: user.profile_picture_url ?? null,
|
||||||
|
deleted_at: user.deleted_at ?? null,
|
||||||
|
created_at: user.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authService = {
|
||||||
|
async signup(email: string, password: string, name: string) {
|
||||||
|
const existing = await db('users')
|
||||||
|
.whereRaw('LOWER(email) = LOWER(?)', [email])
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw createError('Email already registered.', 409, 'CONFLICT');
|
||||||
|
}
|
||||||
|
|
||||||
|
const password_hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
||||||
|
|
||||||
|
const [user] = await db('users')
|
||||||
|
.insert({
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
password_hash,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
.returning('*');
|
||||||
|
|
||||||
|
const { token, expiresAt } = signToken(user.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: formatUser(user),
|
||||||
|
token,
|
||||||
|
expires_at: expiresAt.toISOString(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async signin(email: string, password: string) {
|
||||||
|
const user = await db('users')
|
||||||
|
.whereRaw('LOWER(email) = LOWER(?)', [email])
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw createError('Invalid credentials.', 401, 'UNAUTHORIZED');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.password_hash) {
|
||||||
|
throw createError('Invalid credentials.', 401, 'UNAUTHORIZED');
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare(password, user.password_hash);
|
||||||
|
if (!valid) {
|
||||||
|
throw createError('Invalid credentials.', 401, 'UNAUTHORIZED');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.deleted_at) {
|
||||||
|
const err = createError('Account is pending deletion.', 403, 'ACCOUNT_PENDING_DELETION');
|
||||||
|
(err as Record<string, unknown>).deletion_scheduled_at = user.deletion_scheduled_at;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token, expiresAt } = signToken(user.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: formatUser(user),
|
||||||
|
token,
|
||||||
|
expires_at: expiresAt.toISOString(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async googleAuth(idToken: string) {
|
||||||
|
let ticket;
|
||||||
|
try {
|
||||||
|
ticket = await googleClient.verifyIdToken({
|
||||||
|
idToken,
|
||||||
|
audience: config.googleClientId,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw createError('Google token verification failed.', 401, 'INVALID_GOOGLE_TOKEN');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = ticket.getPayload();
|
||||||
|
if (!payload || !payload.email) {
|
||||||
|
throw createError('Google token verification failed.', 401, 'INVALID_GOOGLE_TOKEN');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sub: googleId, email, name = '', picture } = payload;
|
||||||
|
|
||||||
|
// Check for existing user by google_id or email
|
||||||
|
let user = await db('users')
|
||||||
|
.where({ google_id: googleId })
|
||||||
|
.orWhereRaw('LOWER(email) = LOWER(?)', [email])
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (user && user.deleted_at) {
|
||||||
|
const err = createError('Account is pending deletion.', 403, 'ACCOUNT_PENDING_DELETION');
|
||||||
|
(err as Record<string, unknown>).deletion_scheduled_at = user.deletion_scheduled_at;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isNewUser = false;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
[user] = await db('users')
|
||||||
|
.insert({
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
google_id: googleId,
|
||||||
|
name,
|
||||||
|
profile_picture_url: picture ?? null,
|
||||||
|
email_verified: true,
|
||||||
|
})
|
||||||
|
.returning('*');
|
||||||
|
isNewUser = true;
|
||||||
|
} else if (!user.google_id) {
|
||||||
|
// Link google account to existing email account
|
||||||
|
[user] = await db('users')
|
||||||
|
.where({ id: user.id })
|
||||||
|
.update({
|
||||||
|
google_id: googleId,
|
||||||
|
profile_picture_url: user.profile_picture_url ?? picture ?? null,
|
||||||
|
email_verified: true,
|
||||||
|
updated_at: db.fn.now(),
|
||||||
|
})
|
||||||
|
.returning('*');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token, expiresAt } = signToken(user.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: formatUser(user),
|
||||||
|
token,
|
||||||
|
expires_at: expiresAt.toISOString(),
|
||||||
|
is_new_user: isNewUser,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async requestPasswordReset(email: string) {
|
||||||
|
const user = await db('users')
|
||||||
|
.whereRaw('LOWER(email) = LOWER(?)', [email])
|
||||||
|
.whereNull('deleted_at')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
// Always return success — prevents email enumeration
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
// Invalidate any existing unused tokens
|
||||||
|
await db('password_reset_tokens')
|
||||||
|
.where({ user_id: user.id })
|
||||||
|
.whereNull('used_at')
|
||||||
|
.update({ used_at: db.fn.now() });
|
||||||
|
|
||||||
|
const rawToken = crypto.randomBytes(32).toString('hex');
|
||||||
|
const token_hash = await bcrypt.hash(rawToken, BCRYPT_ROUNDS);
|
||||||
|
const expires_at = new Date(
|
||||||
|
Date.now() + PASSWORD_RESET_EXPIRES_HOURS * 60 * 60 * 1000
|
||||||
|
).toISOString();
|
||||||
|
|
||||||
|
await db('password_reset_tokens').insert({
|
||||||
|
user_id: user.id,
|
||||||
|
token_hash,
|
||||||
|
expires_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
await emailService.sendPasswordReset(user.email, rawToken);
|
||||||
|
},
|
||||||
|
|
||||||
|
async confirmPasswordReset(rawToken: string, newPassword: string) {
|
||||||
|
// Find all unexpired, unused tokens and check each
|
||||||
|
const tokens = await db('password_reset_tokens')
|
||||||
|
.whereNull('used_at')
|
||||||
|
.where('expires_at', '>', db.fn.now())
|
||||||
|
.orderBy('created_at', 'desc');
|
||||||
|
|
||||||
|
let matchedToken = null;
|
||||||
|
for (const t of tokens) {
|
||||||
|
const match = await bcrypt.compare(rawToken, t.token_hash);
|
||||||
|
if (match) {
|
||||||
|
matchedToken = t;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matchedToken) {
|
||||||
|
throw createError('Token is invalid or has expired.', 401, 'INVALID_TOKEN');
|
||||||
|
}
|
||||||
|
|
||||||
|
const password_hash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
await trx('users')
|
||||||
|
.where({ id: matchedToken.user_id })
|
||||||
|
.update({ password_hash, updated_at: trx.fn.now() });
|
||||||
|
|
||||||
|
await trx('password_reset_tokens')
|
||||||
|
.where({ id: matchedToken.id })
|
||||||
|
.update({ used_at: trx.fn.now() });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteAccount(userId: string) {
|
||||||
|
const deletionScheduledAt = new Date(
|
||||||
|
Date.now() + ACCOUNT_DELETION_DAYS * 24 * 60 * 60 * 1000
|
||||||
|
).toISOString();
|
||||||
|
|
||||||
|
await db('users').where({ id: userId }).update({
|
||||||
|
deleted_at: db.fn.now(),
|
||||||
|
deletion_scheduled_at: deletionScheduledAt,
|
||||||
|
updated_at: db.fn.now(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async restoreAccount(userId: string) {
|
||||||
|
const user = await db('users').where({ id: userId }).first();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw createError('Account not found.', 410, 'GONE');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.deleted_at) {
|
||||||
|
// Not deleted — nothing to restore, just return user
|
||||||
|
return formatUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.deletion_scheduled_at && new Date(user.deletion_scheduled_at) <= new Date()) {
|
||||||
|
throw createError('Account restoration window has expired.', 410, 'GONE');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [restored] = await db('users')
|
||||||
|
.where({ id: userId })
|
||||||
|
.update({
|
||||||
|
deleted_at: null,
|
||||||
|
deletion_scheduled_at: null,
|
||||||
|
updated_at: db.fn.now(),
|
||||||
|
})
|
||||||
|
.returning('*');
|
||||||
|
|
||||||
|
return formatUser(restored);
|
||||||
|
},
|
||||||
|
};
|
||||||
29
src/services/emailService.ts
Normal file
29
src/services/emailService.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { config } from '../config/env';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
export const emailService = {
|
||||||
|
async sendPasswordReset(toEmail: string, rawToken: string): Promise<void> {
|
||||||
|
const resetUrl = `${config.passwordResetUrl}?token=${rawToken}`;
|
||||||
|
|
||||||
|
if (config.isTest || !config.sendgridApiKey) {
|
||||||
|
logger.info({ toEmail, resetUrl }, 'Password reset email (not sent in test/dev without key)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sgMail = await import('@sendgrid/mail');
|
||||||
|
sgMail.default.setApiKey(config.sendgridApiKey);
|
||||||
|
|
||||||
|
await sgMail.default.send({
|
||||||
|
to: toEmail,
|
||||||
|
from: config.sendgridFromEmail,
|
||||||
|
subject: 'Reset your Pantree password',
|
||||||
|
text: `Click the link to reset your password: ${resetUrl}\n\nThis link expires in 1 hour.`,
|
||||||
|
html: `<p>Click the link to reset your password:</p><p><a href="${resetUrl}">${resetUrl}</a></p><p>This link expires in 1 hour.</p>`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, 'Failed to send password reset email');
|
||||||
|
// Do not throw — prevents email enumeration via timing
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const { getDb } = require('../db/knex');
|
|
||||||
const { AppError } = require('../utils/errors');
|
|
||||||
|
|
||||||
function formatItem(row) {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
item_name: row.item_name,
|
|
||||||
quantity: row.quantity,
|
|
||||||
last_modified: row.last_modified,
|
|
||||||
created_at: row.created_at,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns all pantry items for the authenticated user, ordered alphabetically.
|
|
||||||
*/
|
|
||||||
async function getPantryItems(userId) {
|
|
||||||
const db = getDb();
|
|
||||||
const items = await db('pantry_items')
|
|
||||||
.where({ user_id: userId })
|
|
||||||
.orderBy('item_name_lower', 'asc');
|
|
||||||
return items.map(formatItem);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds a new pantry item.
|
|
||||||
* Rejects with 409 if an item with the same name already exists (case-insensitive).
|
|
||||||
* Auto-merge is intentionally not performed — explicit > implicit.
|
|
||||||
*/
|
|
||||||
async function addPantryItem(userId, { item_name, quantity }) {
|
|
||||||
const db = getDb();
|
|
||||||
const trimmedName = item_name.trim();
|
|
||||||
|
|
||||||
const existing = await db('pantry_items')
|
|
||||||
.where({ user_id: userId })
|
|
||||||
.whereRaw('item_name_lower = LOWER(?)', [trimmedName])
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
throw new AppError(
|
|
||||||
409,
|
|
||||||
'DUPLICATE_ITEM',
|
|
||||||
`'${existing.item_name}' already exists in your pantry.`,
|
|
||||||
{ existing_item: formatItem(existing) }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [item] = await db('pantry_items')
|
|
||||||
.insert({ user_id: userId, item_name: trimmedName, quantity })
|
|
||||||
.returning('*');
|
|
||||||
|
|
||||||
return formatItem(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the quantity of an existing pantry item.
|
|
||||||
* Server sets last_modified = NOW() — server clock is authoritative.
|
|
||||||
* Verifies ownership: item must belong to the requesting user.
|
|
||||||
*/
|
|
||||||
async function updatePantryItem(userId, itemId, { quantity }) {
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
const existing = await db('pantry_items')
|
|
||||||
.where({ id: itemId, user_id: userId })
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
throw new AppError(404, 'NOT_FOUND', 'Pantry item not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const [item] = await db('pantry_items')
|
|
||||||
.where({ id: itemId, user_id: userId })
|
|
||||||
.update({ quantity, last_modified: now, updated_at: now })
|
|
||||||
.returning('*');
|
|
||||||
|
|
||||||
return formatItem(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a pantry item and records a tombstone for sync.
|
|
||||||
* Verifies ownership before deletion.
|
|
||||||
*/
|
|
||||||
async function deletePantryItem(userId, itemId) {
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
const existing = await db('pantry_items')
|
|
||||||
.where({ id: itemId, user_id: userId })
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
throw new AppError(404, 'NOT_FOUND', 'Pantry item not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
|
||||||
await trx('pantry_items').where({ id: itemId, user_id: userId }).delete();
|
|
||||||
await trx('deleted_records').insert({
|
|
||||||
user_id: userId,
|
|
||||||
record_type: 'pantry_item',
|
|
||||||
record_id: itemId,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
getPantryItems,
|
|
||||||
addPantryItem,
|
|
||||||
updatePantryItem,
|
|
||||||
deletePantryItem,
|
|
||||||
};
|
|
||||||
73
src/services/pantryService.ts
Normal file
73
src/services/pantryService.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import db from '../db/connection';
|
||||||
|
import { createError } from '../middleware/errorHandler';
|
||||||
|
|
||||||
|
export const pantryService = {
|
||||||
|
async getItems(userId: string) {
|
||||||
|
const items = await db('pantry_items')
|
||||||
|
.where({ user_id: userId })
|
||||||
|
.select('id', 'item_name', 'quantity', 'last_modified', 'created_at')
|
||||||
|
.orderBy('item_name_lower', 'asc');
|
||||||
|
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
|
||||||
|
async addItem(userId: string, itemName: string, quantity: number) {
|
||||||
|
const existing = await db('pantry_items')
|
||||||
|
.where({ user_id: userId, item_name_lower: itemName.toLowerCase() })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw createError(
|
||||||
|
`Item '${itemName}' already exists in your pantry.`,
|
||||||
|
409,
|
||||||
|
'DUPLICATE_ITEM'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = await db('pantry_items')
|
||||||
|
.insert({
|
||||||
|
user_id: userId,
|
||||||
|
item_name: itemName,
|
||||||
|
item_name_lower: itemName.toLowerCase(),
|
||||||
|
quantity,
|
||||||
|
last_modified: db.fn.now(),
|
||||||
|
})
|
||||||
|
.returning(['id', 'item_name', 'quantity', 'last_modified', 'created_at']);
|
||||||
|
|
||||||
|
return item;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateItem(userId: string, itemId: string, quantity: number) {
|
||||||
|
const [item] = await db('pantry_items')
|
||||||
|
.where({ id: itemId, user_id: userId })
|
||||||
|
.update({
|
||||||
|
quantity,
|
||||||
|
last_modified: db.fn.now(),
|
||||||
|
updated_at: db.fn.now(),
|
||||||
|
})
|
||||||
|
.returning(['id', 'item_name', 'quantity', 'last_modified', 'created_at']);
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
throw createError('Pantry item not found.', 404, 'NOT_FOUND');
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteItem(userId: string, itemId: string) {
|
||||||
|
const deleted = await db('pantry_items')
|
||||||
|
.where({ id: itemId, user_id: userId })
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
throw createError('Pantry item not found.', 404, 'NOT_FOUND');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record tombstone for sync
|
||||||
|
await db('deleted_records').insert({
|
||||||
|
user_id: userId,
|
||||||
|
table_name: 'pantry_items',
|
||||||
|
record_id: itemId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const { getDb } = require('../db/knex');
|
|
||||||
const { AppError } = require('../utils/errors');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Computes availability status for a recipe given the user's pantry.
|
|
||||||
* Matching is case-insensitive via item_name_lower.
|
|
||||||
*/
|
|
||||||
function computeAvailability(ingredients, pantryNameSet) {
|
|
||||||
const total = ingredients.length;
|
|
||||||
let available = 0;
|
|
||||||
const missing = [];
|
|
||||||
|
|
||||||
for (const ing of ingredients) {
|
|
||||||
if (pantryNameSet.has(ing.item_name_lower)) {
|
|
||||||
available++;
|
|
||||||
} else {
|
|
||||||
missing.push(ing.item_name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let status;
|
|
||||||
if (total === 0) {
|
|
||||||
status = 'can_make';
|
|
||||||
} else if (available === total) {
|
|
||||||
status = 'can_make';
|
|
||||||
} else if (available > 0) {
|
|
||||||
status = 'partial';
|
|
||||||
} else {
|
|
||||||
status = 'missing_all';
|
|
||||||
}
|
|
||||||
|
|
||||||
return { status, available_count: available, total_count: total, missing_ingredients: missing };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a recipe row with its ingredients for the list endpoint.
|
|
||||||
* Returns a summary (no instructions).
|
|
||||||
*/
|
|
||||||
function formatRecipeSummary(recipe, ingredients, pantryNameSet) {
|
|
||||||
const availability = computeAvailability(ingredients, pantryNameSet);
|
|
||||||
return {
|
|
||||||
id: recipe.id,
|
|
||||||
name: recipe.name,
|
|
||||||
servings: recipe.servings,
|
|
||||||
ingredient_count: ingredients.length,
|
|
||||||
availability,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a full recipe detail with scaled ingredients and pantry status.
|
|
||||||
*/
|
|
||||||
function formatRecipeDetail(recipe, ingredients, pantryNameSet, scale) {
|
|
||||||
const scaledIngredients = ingredients.map((ing) => ({
|
|
||||||
id: ing.id,
|
|
||||||
item_name: ing.item_name,
|
|
||||||
quantity: parseFloat((parseFloat(ing.quantity) * scale).toFixed(4)),
|
|
||||||
unit: ing.unit,
|
|
||||||
in_pantry: pantryNameSet.has(ing.item_name_lower),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const availability = computeAvailability(ingredients, pantryNameSet);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: recipe.id,
|
|
||||||
name: recipe.name,
|
|
||||||
servings: recipe.servings,
|
|
||||||
scaled_servings: recipe.servings * scale,
|
|
||||||
instructions: recipe.instructions,
|
|
||||||
ingredients: scaledIngredients,
|
|
||||||
availability,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns all recipes with pantry-based availability.
|
|
||||||
* Supports filter: 'all' | 'available' | 'partial'
|
|
||||||
* Supports scale: 1 | 2 | 3
|
|
||||||
*/
|
|
||||||
async function getRecipes(userId, { filter = 'all', scale = 1 }) {
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
// Fetch user's pantry as a Set of lowercase names for O(1) lookup
|
|
||||||
const pantryRows = await db('pantry_items')
|
|
||||||
.where({ user_id: userId })
|
|
||||||
.select('item_name_lower');
|
|
||||||
const pantryNameSet = new Set(pantryRows.map((r) => r.item_name_lower));
|
|
||||||
|
|
||||||
// Fetch all recipes with their ingredients in one query
|
|
||||||
const recipes = await db('recipes').orderBy('name', 'asc');
|
|
||||||
const recipeIds = recipes.map((r) => r.id);
|
|
||||||
|
|
||||||
let ingredientRows = [];
|
|
||||||
if (recipeIds.length > 0) {
|
|
||||||
ingredientRows = await db('recipe_ingredients')
|
|
||||||
.whereIn('recipe_id', recipeIds)
|
|
||||||
.orderBy('item_name_lower', 'asc');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group ingredients by recipe_id
|
|
||||||
const ingredientsByRecipe = {};
|
|
||||||
for (const ing of ingredientRows) {
|
|
||||||
if (!ingredientsByRecipe[ing.recipe_id]) {
|
|
||||||
ingredientsByRecipe[ing.recipe_id] = [];
|
|
||||||
}
|
|
||||||
ingredientsByRecipe[ing.recipe_id].push(ing);
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = recipes.map((recipe) => {
|
|
||||||
const ingredients = ingredientsByRecipe[recipe.id] || [];
|
|
||||||
return formatRecipeSummary(recipe, ingredients, pantryNameSet);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Apply filter
|
|
||||||
if (filter === 'available') {
|
|
||||||
result = result.filter((r) => r.availability.status === 'can_make');
|
|
||||||
} else if (filter === 'partial') {
|
|
||||||
result = result.filter(
|
|
||||||
(r) => r.availability.status === 'can_make' || r.availability.status === 'partial'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns full recipe detail for a single recipe.
|
|
||||||
* Verifies the recipe exists — recipes are global (not user-scoped).
|
|
||||||
*/
|
|
||||||
async function getRecipeById(userId, recipeId, { scale = 1 }) {
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
const recipe = await db('recipes').where({ id: recipeId }).first();
|
|
||||||
if (!recipe) {
|
|
||||||
throw new AppError(404, 'NOT_FOUND', 'Recipe not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const ingredients = await db('recipe_ingredients')
|
|
||||||
.where({ recipe_id: recipeId })
|
|
||||||
.orderBy('item_name_lower', 'asc');
|
|
||||||
|
|
||||||
const pantryRows = await db('pantry_items')
|
|
||||||
.where({ user_id: userId })
|
|
||||||
.select('item_name_lower');
|
|
||||||
const pantryNameSet = new Set(pantryRows.map((r) => r.item_name_lower));
|
|
||||||
|
|
||||||
return formatRecipeDetail(recipe, ingredients, pantryNameSet, scale);
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { getRecipes, getRecipeById };
|
|
||||||
136
src/services/recipeService.ts
Normal file
136
src/services/recipeService.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import db from '../db/connection';
|
||||||
|
import { createError } from '../middleware/errorHandler';
|
||||||
|
import { DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT } from '../config/constants';
|
||||||
|
|
||||||
|
export const recipeService = {
|
||||||
|
async getRecipes(
|
||||||
|
userId: string,
|
||||||
|
filter: 'all' | 'can_make' | 'can_partially_make',
|
||||||
|
page: number,
|
||||||
|
limit: number,
|
||||||
|
search?: string
|
||||||
|
) {
|
||||||
|
const safeLimit = Math.min(limit, MAX_PAGE_LIMIT);
|
||||||
|
const offset = (page - 1) * safeLimit;
|
||||||
|
|
||||||
|
// Get user's pantry item names (lowercase)
|
||||||
|
const pantryItems = await db('pantry_items')
|
||||||
|
.where({ user_id: userId })
|
||||||
|
.pluck('item_name_lower');
|
||||||
|
|
||||||
|
const pantrySet = new Set(pantryItems);
|
||||||
|
|
||||||
|
// Base query
|
||||||
|
let query = db('recipes').select('recipes.*');
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
query = query.whereRaw('LOWER(recipes.name) LIKE ?', [`%${search.toLowerCase()}%`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allRecipes = await query.orderBy('recipes.name', 'asc');
|
||||||
|
|
||||||
|
// Get ingredients for all recipes in one query
|
||||||
|
const recipeIds = allRecipes.map((r) => r.id);
|
||||||
|
const allIngredients = recipeIds.length
|
||||||
|
? await db('recipe_ingredients').whereIn('recipe_id', recipeIds)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const ingredientsByRecipe = new Map<string, typeof allIngredients>();
|
||||||
|
for (const ing of allIngredients) {
|
||||||
|
if (!ingredientsByRecipe.has(ing.recipe_id)) {
|
||||||
|
ingredientsByRecipe.set(ing.recipe_id, []);
|
||||||
|
}
|
||||||
|
ingredientsByRecipe.get(ing.recipe_id)!.push(ing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute availability for each recipe
|
||||||
|
const enriched = allRecipes.map((recipe) => {
|
||||||
|
const ingredients = ingredientsByRecipe.get(recipe.id) ?? [];
|
||||||
|
const ingredientCount = ingredients.length;
|
||||||
|
const availableCount = ingredients.filter((i) =>
|
||||||
|
pantrySet.has(i.item_name_lower)
|
||||||
|
).length;
|
||||||
|
const canMake = ingredientCount > 0 && availableCount === ingredientCount;
|
||||||
|
const canPartiallyMake = availableCount > 0 && availableCount < ingredientCount;
|
||||||
|
const availabilityPct =
|
||||||
|
ingredientCount > 0
|
||||||
|
? parseFloat(((availableCount / ingredientCount) * 100).toFixed(2))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: recipe.id,
|
||||||
|
name: recipe.name,
|
||||||
|
servings: recipe.servings,
|
||||||
|
ingredient_count: ingredientCount,
|
||||||
|
available_ingredient_count: availableCount,
|
||||||
|
can_make: canMake,
|
||||||
|
can_partially_make: canPartiallyMake,
|
||||||
|
availability_percentage: availabilityPct,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply filter
|
||||||
|
let filtered = enriched;
|
||||||
|
if (filter === 'can_make') {
|
||||||
|
filtered = enriched.filter((r) => r.can_make);
|
||||||
|
} else if (filter === 'can_partially_make') {
|
||||||
|
filtered = enriched.filter((r) => r.can_partially_make);
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = filtered.length;
|
||||||
|
const totalPages = Math.ceil(total / safeLimit);
|
||||||
|
const paginated = filtered.slice(offset, offset + safeLimit);
|
||||||
|
|
||||||
|
return {
|
||||||
|
recipes: paginated,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit: safeLimit,
|
||||||
|
total,
|
||||||
|
total_pages: totalPages,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async getRecipeById(recipeId: string, userId: string, scaleFactor: number) {
|
||||||
|
const recipe = await db('recipes').where({ id: recipeId }).first();
|
||||||
|
|
||||||
|
if (!recipe) {
|
||||||
|
throw createError('Recipe not found.', 404, 'NOT_FOUND');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ingredients = await db('recipe_ingredients').where({ recipe_id: recipeId });
|
||||||
|
|
||||||
|
const pantryItems = await db('pantry_items')
|
||||||
|
.where({ user_id: userId })
|
||||||
|
.pluck('item_name_lower');
|
||||||
|
|
||||||
|
const pantrySet = new Set(pantryItems);
|
||||||
|
|
||||||
|
const scaledIngredients = ingredients.map((ing) => ({
|
||||||
|
id: ing.id,
|
||||||
|
item_name: ing.item_name,
|
||||||
|
quantity: parseFloat((parseFloat(ing.quantity) * scaleFactor).toFixed(4)),
|
||||||
|
original_quantity: parseFloat(ing.quantity),
|
||||||
|
unit: ing.unit,
|
||||||
|
in_pantry: pantrySet.has(ing.item_name_lower),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const availableCount = scaledIngredients.filter((i) => i.in_pantry).length;
|
||||||
|
const canMake =
|
||||||
|
scaledIngredients.length > 0 && availableCount === scaledIngredients.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: recipe.id,
|
||||||
|
name: recipe.name,
|
||||||
|
servings: recipe.servings,
|
||||||
|
scaled_servings: recipe.servings * scaleFactor,
|
||||||
|
scale_factor: scaleFactor,
|
||||||
|
instructions: recipe.instructions,
|
||||||
|
ingredients: scaledIngredients,
|
||||||
|
can_make: canMake,
|
||||||
|
available_ingredient_count: availableCount,
|
||||||
|
ingredient_count: scaledIngredients.length,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
267
src/services/shoppingListService.ts
Normal file
267
src/services/shoppingListService.ts
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import db from '../db/connection';
|
||||||
|
import { createError } from '../middleware/errorHandler';
|
||||||
|
|
||||||
|
async function getListForUser(listId: string, userId: string) {
|
||||||
|
const list = await db('shopping_lists')
|
||||||
|
.where({ id: listId, user_id: userId })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!list) {
|
||||||
|
throw createError('Shopping list not found.', 404, 'NOT_FOUND');
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const shoppingListService = {
|
||||||
|
async getLists(userId: string) {
|
||||||
|
const lists = await db('shopping_lists')
|
||||||
|
.where({ user_id: userId })
|
||||||
|
.select('id', 'list_name', 'last_modified', 'created_at')
|
||||||
|
.orderBy('created_at', 'desc');
|
||||||
|
|
||||||
|
// Get item counts in one query
|
||||||
|
const listIds = lists.map((l) => l.id);
|
||||||
|
const counts = listIds.length
|
||||||
|
? await db('shopping_list_items')
|
||||||
|
.whereIn('shopping_list_id', listIds)
|
||||||
|
.select('shopping_list_id')
|
||||||
|
.count('id as item_count')
|
||||||
|
.sum(db.raw('CASE WHEN checked_off THEN 1 ELSE 0 END as checked_count'))
|
||||||
|
.groupBy('shopping_list_id')
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const countMap = new Map(
|
||||||
|
counts.map((c) => [
|
||||||
|
c.shopping_list_id,
|
||||||
|
{ item_count: parseInt(String(c.item_count), 10), checked_count: parseInt(String(c.checked_count), 10) },
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
return lists.map((l) => ({
|
||||||
|
...l,
|
||||||
|
item_count: countMap.get(l.id)?.item_count ?? 0,
|
||||||
|
checked_count: countMap.get(l.id)?.checked_count ?? 0,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
async createList(userId: string, listName: string) {
|
||||||
|
const [list] = await db('shopping_lists')
|
||||||
|
.insert({ user_id: userId, list_name: listName })
|
||||||
|
.returning(['id', 'list_name', 'last_modified', 'created_at']);
|
||||||
|
|
||||||
|
return { ...list, item_count: 0, checked_count: 0 };
|
||||||
|
},
|
||||||
|
|
||||||
|
async getListById(listId: string, userId: string) {
|
||||||
|
const list = await getListForUser(listId, userId);
|
||||||
|
|
||||||
|
const items = await db('shopping_list_items')
|
||||||
|
.where({ shopping_list_id: listId })
|
||||||
|
.select('id', 'item_name', 'quantity', 'unit', 'checked_off', 'last_modified')
|
||||||
|
.orderBy('item_name_lower', 'asc');
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: list.id,
|
||||||
|
list_name: list.list_name,
|
||||||
|
last_modified: list.last_modified,
|
||||||
|
created_at: list.created_at,
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteList(listId: string, userId: string) {
|
||||||
|
const deleted = await db('shopping_lists')
|
||||||
|
.where({ id: listId, user_id: userId })
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
throw createError('Shopping list not found.', 404, 'NOT_FOUND');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db('deleted_records').insert({
|
||||||
|
user_id: userId,
|
||||||
|
table_name: 'shopping_lists',
|
||||||
|
record_id: listId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async addItem(
|
||||||
|
listId: string,
|
||||||
|
userId: string,
|
||||||
|
itemName: string,
|
||||||
|
quantity: number,
|
||||||
|
unit: string
|
||||||
|
) {
|
||||||
|
await getListForUser(listId, userId);
|
||||||
|
|
||||||
|
const existing = await db('shopping_list_items')
|
||||||
|
.where({
|
||||||
|
shopping_list_id: listId,
|
||||||
|
item_name_lower: itemName.toLowerCase(),
|
||||||
|
unit,
|
||||||
|
})
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const previousQuantity = parseFloat(existing.quantity);
|
||||||
|
const newQuantity = previousQuantity + quantity;
|
||||||
|
|
||||||
|
const [updated] = await db('shopping_list_items')
|
||||||
|
.where({ id: existing.id })
|
||||||
|
.update({
|
||||||
|
quantity: newQuantity,
|
||||||
|
last_modified: db.fn.now(),
|
||||||
|
updated_at: db.fn.now(),
|
||||||
|
})
|
||||||
|
.returning(['id', 'item_name', 'quantity', 'unit', 'checked_off', 'last_modified']);
|
||||||
|
|
||||||
|
// Update list last_modified
|
||||||
|
await db('shopping_lists')
|
||||||
|
.where({ id: listId })
|
||||||
|
.update({ last_modified: db.fn.now(), updated_at: db.fn.now() });
|
||||||
|
|
||||||
|
return { item: updated, merged: true, previous_quantity: previousQuantity };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = await db('shopping_list_items')
|
||||||
|
.insert({
|
||||||
|
shopping_list_id: listId,
|
||||||
|
item_name: itemName,
|
||||||
|
item_name_lower: itemName.toLowerCase(),
|
||||||
|
quantity,
|
||||||
|
unit,
|
||||||
|
last_modified: db.fn.now(),
|
||||||
|
})
|
||||||
|
.returning(['id', 'item_name', 'quantity', 'unit', 'checked_off', 'last_modified']);
|
||||||
|
|
||||||
|
await db('shopping_lists')
|
||||||
|
.where({ id: listId })
|
||||||
|
.update({ last_modified: db.fn.now(), updated_at: db.fn.now() });
|
||||||
|
|
||||||
|
return { item, merged: false };
|
||||||
|
},
|
||||||
|
|
||||||
|
async addRecipesToList(
|
||||||
|
listId: string,
|
||||||
|
userId: string,
|
||||||
|
recipeIds: string[],
|
||||||
|
scaleFactor: number
|
||||||
|
) {
|
||||||
|
await getListForUser(listId, userId);
|
||||||
|
|
||||||
|
// Validate all recipes exist
|
||||||
|
const recipes = await db('recipes').whereIn('id', recipeIds).select('id');
|
||||||
|
if (recipes.length !== recipeIds.length) {
|
||||||
|
throw createError('One or more recipes not found.', 404, 'NOT_FOUND');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ingredients = await db('recipe_ingredients').whereIn('recipe_id', recipeIds);
|
||||||
|
|
||||||
|
let itemsMerged = 0;
|
||||||
|
let itemsCreated = 0;
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
for (const ing of ingredients) {
|
||||||
|
const scaledQty = parseFloat(ing.quantity) * scaleFactor;
|
||||||
|
|
||||||
|
const existing = await trx('shopping_list_items')
|
||||||
|
.where({
|
||||||
|
shopping_list_id: listId,
|
||||||
|
item_name_lower: ing.item_name_lower,
|
||||||
|
unit: ing.unit,
|
||||||
|
})
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await trx('shopping_list_items')
|
||||||
|
.where({ id: existing.id })
|
||||||
|
.update({
|
||||||
|
quantity: parseFloat(existing.quantity) + scaledQty,
|
||||||
|
last_modified: trx.fn.now(),
|
||||||
|
updated_at: trx.fn.now(),
|
||||||
|
});
|
||||||
|
itemsMerged++;
|
||||||
|
} else {
|
||||||
|
await trx('shopping_list_items').insert({
|
||||||
|
shopping_list_id: listId,
|
||||||
|
item_name: ing.item_name,
|
||||||
|
item_name_lower: ing.item_name_lower,
|
||||||
|
quantity: scaledQty,
|
||||||
|
unit: ing.unit,
|
||||||
|
last_modified: trx.fn.now(),
|
||||||
|
});
|
||||||
|
itemsCreated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx('shopping_lists')
|
||||||
|
.where({ id: listId })
|
||||||
|
.update({ last_modified: trx.fn.now(), updated_at: trx.fn.now() });
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedList = await this.getListById(listId, userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
shopping_list: updatedList,
|
||||||
|
recipes_added: recipeIds.length,
|
||||||
|
items_merged: itemsMerged,
|
||||||
|
items_created: itemsCreated,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateItem(
|
||||||
|
listId: string,
|
||||||
|
itemId: string,
|
||||||
|
userId: string,
|
||||||
|
updates: { quantity?: number; unit?: string; checked_off?: boolean }
|
||||||
|
) {
|
||||||
|
await getListForUser(listId, userId);
|
||||||
|
|
||||||
|
const updateData: Record<string, unknown> = {
|
||||||
|
last_modified: db.fn.now(),
|
||||||
|
updated_at: db.fn.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (updates.quantity !== undefined) updateData.quantity = updates.quantity;
|
||||||
|
if (updates.unit !== undefined) updateData.unit = updates.unit;
|
||||||
|
if (updates.checked_off !== undefined) updateData.checked_off = updates.checked_off;
|
||||||
|
|
||||||
|
const [item] = await db('shopping_list_items')
|
||||||
|
.where({ id: itemId, shopping_list_id: listId })
|
||||||
|
.update(updateData)
|
||||||
|
.returning(['id', 'item_name', 'quantity', 'unit', 'checked_off', 'last_modified']);
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
throw createError('Shopping list item not found.', 404, 'NOT_FOUND');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db('shopping_lists')
|
||||||
|
.where({ id: listId })
|
||||||
|
.update({ last_modified: db.fn.now(), updated_at: db.fn.now() });
|
||||||
|
|
||||||
|
return item;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteItem(listId: string, itemId: string, userId: string) {
|
||||||
|
await getListForUser(listId, userId);
|
||||||
|
|
||||||
|
const deleted = await db('shopping_list_items')
|
||||||
|
.where({ id: itemId, shopping_list_id: listId })
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
throw createError('Shopping list item not found.', 404, 'NOT_FOUND');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db('deleted_records').insert({
|
||||||
|
user_id: userId,
|
||||||
|
table_name: 'shopping_list_items',
|
||||||
|
record_id: itemId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db('shopping_lists')
|
||||||
|
.where({ id: listId })
|
||||||
|
.update({ last_modified: db.fn.now(), updated_at: db.fn.now() });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,340 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const { getDb } = require('../db/knex');
|
|
||||||
const { AppError } = require('../utils/errors');
|
|
||||||
|
|
||||||
function formatListSummary(row) {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
list_name: row.list_name,
|
|
||||||
item_count: parseInt(row.item_count || 0, 10),
|
|
||||||
checked_count: parseInt(row.checked_count || 0, 10),
|
|
||||||
last_modified: row.last_modified,
|
|
||||||
created_at: row.created_at,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatItem(row) {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
item_name: row.item_name,
|
|
||||||
quantity: parseFloat(row.quantity),
|
|
||||||
unit: row.unit,
|
|
||||||
checked_off: row.checked_off,
|
|
||||||
last_modified: row.last_modified,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns all shopping lists for the user with item/checked counts.
|
|
||||||
*/
|
|
||||||
async function getShoppingLists(userId) {
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
const lists = await db('shopping_lists as sl')
|
|
||||||
.leftJoin('shopping_list_items as sli', 'sl.id', 'sli.shopping_list_id')
|
|
||||||
.where('sl.user_id', userId)
|
|
||||||
.groupBy('sl.id')
|
|
||||||
.select(
|
|
||||||
'sl.id',
|
|
||||||
'sl.list_name',
|
|
||||||
'sl.last_modified',
|
|
||||||
'sl.created_at',
|
|
||||||
db.raw('COUNT(sli.id) AS item_count'),
|
|
||||||
db.raw('COUNT(sli.id) FILTER (WHERE sli.checked_off = true) AS checked_count')
|
|
||||||
)
|
|
||||||
.orderBy('sl.created_at', 'desc');
|
|
||||||
|
|
||||||
return lists.map(formatListSummary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new empty shopping list.
|
|
||||||
*/
|
|
||||||
async function createShoppingList(userId, { list_name }) {
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
const [list] = await db('shopping_lists')
|
|
||||||
.insert({ user_id: userId, list_name: list_name.trim() })
|
|
||||||
.returning('*');
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: list.id,
|
|
||||||
list_name: list.list_name,
|
|
||||||
item_count: 0,
|
|
||||||
checked_count: 0,
|
|
||||||
last_modified: list.last_modified,
|
|
||||||
created_at: list.created_at,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a shopping list with all its items.
|
|
||||||
* Verifies ownership.
|
|
||||||
*/
|
|
||||||
async function getShoppingListById(userId, listId) {
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
const list = await db('shopping_lists')
|
|
||||||
.where({ id: listId, user_id: userId })
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (!list) {
|
|
||||||
throw new AppError(404, 'NOT_FOUND', 'Shopping list not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = await db('shopping_list_items')
|
|
||||||
.where({ shopping_list_id: listId })
|
|
||||||
.orderBy('item_name_lower', 'asc');
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: list.id,
|
|
||||||
list_name: list.list_name,
|
|
||||||
last_modified: list.last_modified,
|
|
||||||
created_at: list.created_at,
|
|
||||||
items: items.map(formatItem),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a shopping list and all its items.
|
|
||||||
* Records tombstones for sync.
|
|
||||||
* Verifies ownership.
|
|
||||||
*/
|
|
||||||
async function deleteShoppingList(userId, listId) {
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
const list = await db('shopping_lists').where({ id: listId, user_id: userId }).first();
|
|
||||||
if (!list) {
|
|
||||||
throw new AppError(404, 'NOT_FOUND', 'Shopping list not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
|
||||||
const items = await trx('shopping_list_items')
|
|
||||||
.where({ shopping_list_id: listId })
|
|
||||||
.select('id');
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
await trx('deleted_records').insert({
|
|
||||||
user_id: userId,
|
|
||||||
record_type: 'shopping_list_item',
|
|
||||||
record_id: item.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await trx('shopping_lists').where({ id: listId, user_id: userId }).delete();
|
|
||||||
|
|
||||||
await trx('deleted_records').insert({
|
|
||||||
user_id: userId,
|
|
||||||
record_type: 'shopping_list',
|
|
||||||
record_id: listId,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds an item to a shopping list.
|
|
||||||
* Merge logic: same name (case-insensitive) AND same unit → sum quantities.
|
|
||||||
* Different units → separate line items.
|
|
||||||
* Verifies list ownership.
|
|
||||||
*/
|
|
||||||
async function addItemToList(userId, listId, { item_name, quantity, unit }) {
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
const list = await db('shopping_lists').where({ id: listId, user_id: userId }).first();
|
|
||||||
if (!list) {
|
|
||||||
throw new AppError(404, 'NOT_FOUND', 'Shopping list not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedName = item_name.trim();
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
const existing = await db('shopping_list_items')
|
|
||||||
.where({ shopping_list_id: listId, unit })
|
|
||||||
.whereRaw('item_name_lower = LOWER(?)', [trimmedName])
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
const newQty = parseFloat((parseFloat(existing.quantity) + quantity).toFixed(4));
|
|
||||||
const previousQuantity = parseFloat(existing.quantity);
|
|
||||||
|
|
||||||
const [updated] = await db('shopping_list_items')
|
|
||||||
.where({ id: existing.id })
|
|
||||||
.update({ quantity: newQty, last_modified: now, updated_at: now })
|
|
||||||
.returning('*');
|
|
||||||
|
|
||||||
await db('shopping_lists')
|
|
||||||
.where({ id: listId })
|
|
||||||
.update({ last_modified: now, updated_at: now });
|
|
||||||
|
|
||||||
return { item: formatItem(updated), merged: true, previous_quantity: previousQuantity };
|
|
||||||
}
|
|
||||||
|
|
||||||
const [item] = await db('shopping_list_items')
|
|
||||||
.insert({ shopping_list_id: listId, item_name: trimmedName, quantity, unit })
|
|
||||||
.returning('*');
|
|
||||||
|
|
||||||
await db('shopping_lists')
|
|
||||||
.where({ id: listId })
|
|
||||||
.update({ last_modified: now, updated_at: now });
|
|
||||||
|
|
||||||
return { item: formatItem(item), merged: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates a shopping list item (quantity, unit, checked_off).
|
|
||||||
* All fields are optional — only provided fields are patched.
|
|
||||||
* Verifies list ownership and item membership.
|
|
||||||
*/
|
|
||||||
async function updateListItem(userId, listId, itemId, updates) {
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
const list = await db('shopping_lists').where({ id: listId, user_id: userId }).first();
|
|
||||||
if (!list) {
|
|
||||||
throw new AppError(404, 'NOT_FOUND', 'Shopping list not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = await db('shopping_list_items')
|
|
||||||
.where({ id: itemId, shopping_list_id: listId })
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (!item) {
|
|
||||||
throw new AppError(404, 'NOT_FOUND', 'Shopping list item not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const patch = { last_modified: now, updated_at: now };
|
|
||||||
if (updates.quantity !== undefined) patch.quantity = updates.quantity;
|
|
||||||
if (updates.unit !== undefined) patch.unit = updates.unit;
|
|
||||||
if (updates.checked_off !== undefined) patch.checked_off = updates.checked_off;
|
|
||||||
|
|
||||||
const [updated] = await db('shopping_list_items')
|
|
||||||
.where({ id: itemId })
|
|
||||||
.update(patch)
|
|
||||||
.returning('*');
|
|
||||||
|
|
||||||
await db('shopping_lists')
|
|
||||||
.where({ id: listId })
|
|
||||||
.update({ last_modified: now, updated_at: now });
|
|
||||||
|
|
||||||
return formatItem(updated);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a shopping list item and records a tombstone for sync.
|
|
||||||
* Verifies list ownership and item membership.
|
|
||||||
*/
|
|
||||||
async function deleteListItem(userId, listId, itemId) {
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
const list = await db('shopping_lists').where({ id: listId, user_id: userId }).first();
|
|
||||||
if (!list) {
|
|
||||||
throw new AppError(404, 'NOT_FOUND', 'Shopping list not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = await db('shopping_list_items')
|
|
||||||
.where({ id: itemId, shopping_list_id: listId })
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (!item) {
|
|
||||||
throw new AppError(404, 'NOT_FOUND', 'Shopping list item not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
await db.transaction(async (trx) => {
|
|
||||||
await trx('shopping_list_items').where({ id: itemId }).delete();
|
|
||||||
await trx('deleted_records').insert({
|
|
||||||
user_id: userId,
|
|
||||||
record_type: 'shopping_list_item',
|
|
||||||
record_id: itemId,
|
|
||||||
});
|
|
||||||
await trx('shopping_lists')
|
|
||||||
.where({ id: listId })
|
|
||||||
.update({ last_modified: now, updated_at: now });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds ingredients from one or more recipes to a shopping list.
|
|
||||||
* Merge logic: same name (case-insensitive) + same unit → sum quantities.
|
|
||||||
* Different units → separate line items.
|
|
||||||
* scale: 1 | 2 | 3 — multiplies all ingredient quantities.
|
|
||||||
*/
|
|
||||||
async function addRecipesToList(userId, listId, { recipe_ids, scale = 1 }) {
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
const list = await db('shopping_lists').where({ id: listId, user_id: userId }).first();
|
|
||||||
if (!list) {
|
|
||||||
throw new AppError(404, 'NOT_FOUND', 'Shopping list not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate all recipes exist before touching the list
|
|
||||||
const recipes = await db('recipes').whereIn('id', recipe_ids);
|
|
||||||
if (recipes.length !== recipe_ids.length) {
|
|
||||||
throw new AppError(404, 'NOT_FOUND', 'One or more recipes not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const recipeIngredients = await db('recipe_ingredients').whereIn('recipe_id', recipe_ids);
|
|
||||||
|
|
||||||
let itemsMerged = 0;
|
|
||||||
let itemsCreated = 0;
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
|
||||||
for (const ing of recipeIngredients) {
|
|
||||||
const scaledQty = parseFloat((parseFloat(ing.quantity) * scale).toFixed(4));
|
|
||||||
|
|
||||||
const existing = await trx('shopping_list_items')
|
|
||||||
.where({ shopping_list_id: listId, unit: ing.unit })
|
|
||||||
.whereRaw('item_name_lower = LOWER(?)', [ing.item_name])
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
const newQty = parseFloat((parseFloat(existing.quantity) + scaledQty).toFixed(4));
|
|
||||||
await trx('shopping_list_items')
|
|
||||||
.where({ id: existing.id })
|
|
||||||
.update({ quantity: newQty, last_modified: now, updated_at: now });
|
|
||||||
itemsMerged++;
|
|
||||||
} else {
|
|
||||||
await trx('shopping_list_items').insert({
|
|
||||||
shopping_list_id: listId,
|
|
||||||
item_name: ing.item_name,
|
|
||||||
quantity: scaledQty,
|
|
||||||
unit: ing.unit,
|
|
||||||
});
|
|
||||||
itemsCreated++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await trx('shopping_lists')
|
|
||||||
.where({ id: listId })
|
|
||||||
.update({ last_modified: now, updated_at: now });
|
|
||||||
});
|
|
||||||
|
|
||||||
const updatedItems = await db('shopping_list_items')
|
|
||||||
.where({ shopping_list_id: listId })
|
|
||||||
.orderBy('item_name_lower', 'asc');
|
|
||||||
|
|
||||||
return {
|
|
||||||
shopping_list: {
|
|
||||||
id: list.id,
|
|
||||||
list_name: list.list_name,
|
|
||||||
last_modified: now,
|
|
||||||
items: updatedItems.map(formatItem),
|
|
||||||
},
|
|
||||||
recipes_added: recipes.length,
|
|
||||||
items_merged: itemsMerged,
|
|
||||||
items_created: itemsCreated,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
getShoppingLists,
|
|
||||||
createShoppingList,
|
|
||||||
getShoppingListById,
|
|
||||||
deleteShoppingList,
|
|
||||||
addItemToList,
|
|
||||||
updateListItem,
|
|
||||||
deleteListItem,
|
|
||||||
addRecipesToList,
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user