feat: implement advanced tagging system, tag weighting, and app focus refactoring

- Implemented reusable TagSelector component with i18n support
- Added tag weighting system (popularity scores 1-5)
- Created admin panel for tag management
- Integrated Nebius AI and Brave Search for 'Magic Scan'
- Refactored app focus: removed bottle status, updated counters, and displayed extended bottle details
- Updated i18n for German and English
- Added database migration scripts
This commit is contained in:
2025-12-19 12:58:44 +01:00
parent 9eb9b41061
commit b2a1d292da
30 changed files with 2420 additions and 194 deletions

686
.aiinstruct2 Normal file
View File

@@ -0,0 +1,686 @@
[
{
"name": "auth_rls_initplan",
"title": "Auth RLS Initialization Plan",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if calls to `auth.<function>()` in RLS policies are being unnecessarily re-evaluated for each row",
"detail": "Table `public.buddies` has a row level security policy `Manage own buddies` that re-evaluates an auth.<function>() for each row. This produces suboptimal query performance at scale. Resolve the issue by replacing `auth.<function>()` with `(select auth.<function>())`. See [docs](https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select) for more info.",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan",
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "auth_rls_init_plan_public_buddies_Manage own buddies"
},
{
"name": "auth_rls_initplan",
"title": "Auth RLS Initialization Plan",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if calls to `auth.<function>()` in RLS policies are being unnecessarily re-evaluated for each row",
"detail": "Table `public.tasting_sessions` has a row level security policy `Manage own sessions` that re-evaluates an auth.<function>() for each row. This produces suboptimal query performance at scale. Resolve the issue by replacing `auth.<function>()` with `(select auth.<function>())`. See [docs](https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select) for more info.",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan",
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "auth_rls_init_plan_public_tasting_sessions_Manage own sessions"
},
{
"name": "auth_rls_initplan",
"title": "Auth RLS Initialization Plan",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if calls to `auth.<function>()` in RLS policies are being unnecessarily re-evaluated for each row",
"detail": "Table `public.tastings` has a row level security policy `tastings_owner_all` that re-evaluates an auth.<function>() for each row. This produces suboptimal query performance at scale. Resolve the issue by replacing `auth.<function>()` with `(select auth.<function>())`. See [docs](https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select) for more info.",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan",
"metadata": "{\"name\":\"tastings\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "auth_rls_init_plan_public_tastings_tastings_owner_all"
},
{
"name": "auth_rls_initplan",
"title": "Auth RLS Initialization Plan",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if calls to `auth.<function>()` in RLS policies are being unnecessarily re-evaluated for each row",
"detail": "Table `public.tasting_tags` has a row level security policy `tags_owner_all` that re-evaluates an auth.<function>() for each row. This produces suboptimal query performance at scale. Resolve the issue by replacing `auth.<function>()` with `(select auth.<function>())`. See [docs](https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select) for more info.",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan",
"metadata": "{\"name\":\"tasting_tags\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "auth_rls_init_plan_public_tasting_tags_tags_owner_all"
},
{
"name": "auth_rls_initplan",
"title": "Auth RLS Initialization Plan",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if calls to `auth.<function>()` in RLS policies are being unnecessarily re-evaluated for each row",
"detail": "Table `public.session_participants` has a row level security policy `session_owner_all` that re-evaluates an auth.<function>() for each row. This produces suboptimal query performance at scale. Resolve the issue by replacing `auth.<function>()` with `(select auth.<function>())`. See [docs](https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select) for more info.",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan",
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "auth_rls_init_plan_public_session_participants_session_owner_all"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.bottles` has multiple permissive policies for role `authenticated` for action `DELETE`. Policies include `{\"Relaxed bottles access\",bottles_owner_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"bottles\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_bottles_authenticated_DELETE"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.bottles` has multiple permissive policies for role `authenticated` for action `INSERT`. Policies include `{\"Relaxed bottles access\",bottles_owner_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"bottles\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_bottles_authenticated_INSERT"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.bottles` has multiple permissive policies for role `authenticated` for action `SELECT`. Policies include `{\"Relaxed bottles access\",bottles_owner_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"bottles\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_bottles_authenticated_SELECT"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.bottles` has multiple permissive policies for role `authenticated` for action `UPDATE`. Policies include `{\"Relaxed bottles access\",bottles_owner_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"bottles\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_bottles_authenticated_UPDATE"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.buddies` has multiple permissive policies for role `anon` for action `DELETE`. Policies include `{\"Manage own buddies\",buddies_access_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_buddies_anon_DELETE"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.buddies` has multiple permissive policies for role `anon` for action `INSERT`. Policies include `{\"Manage own buddies\",buddies_access_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_buddies_anon_INSERT"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.buddies` has multiple permissive policies for role `anon` for action `SELECT`. Policies include `{\"Manage own buddies\",buddies_access_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_buddies_anon_SELECT"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.buddies` has multiple permissive policies for role `anon` for action `UPDATE`. Policies include `{\"Manage own buddies\",buddies_access_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_buddies_anon_UPDATE"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.buddies` has multiple permissive policies for role `authenticated` for action `DELETE`. Policies include `{\"Manage own buddies\",buddies_access_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_buddies_authenticated_DELETE"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.buddies` has multiple permissive policies for role `authenticated` for action `INSERT`. Policies include `{\"Manage own buddies\",buddies_access_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_buddies_authenticated_INSERT"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.buddies` has multiple permissive policies for role `authenticated` for action `SELECT`. Policies include `{\"Manage own buddies\",buddies_access_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_buddies_authenticated_SELECT"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.buddies` has multiple permissive policies for role `authenticated` for action `UPDATE`. Policies include `{\"Manage own buddies\",buddies_access_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_buddies_authenticated_UPDATE"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.buddies` has multiple permissive policies for role `authenticator` for action `DELETE`. Policies include `{\"Manage own buddies\",buddies_access_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_buddies_authenticator_DELETE"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.buddies` has multiple permissive policies for role `authenticator` for action `INSERT`. Policies include `{\"Manage own buddies\",buddies_access_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_buddies_authenticator_INSERT"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.buddies` has multiple permissive policies for role `authenticator` for action `SELECT`. Policies include `{\"Manage own buddies\",buddies_access_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_buddies_authenticator_SELECT"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.buddies` has multiple permissive policies for role `authenticator` for action `UPDATE`. Policies include `{\"Manage own buddies\",buddies_access_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_buddies_authenticator_UPDATE"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.buddies` has multiple permissive policies for role `dashboard_user` for action `DELETE`. Policies include `{\"Manage own buddies\",buddies_access_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_buddies_dashboard_user_DELETE"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.buddies` has multiple permissive policies for role `dashboard_user` for action `INSERT`. Policies include `{\"Manage own buddies\",buddies_access_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_buddies_dashboard_user_INSERT"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.buddies` has multiple permissive policies for role `dashboard_user` for action `SELECT`. Policies include `{\"Manage own buddies\",buddies_access_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_buddies_dashboard_user_SELECT"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.buddies` has multiple permissive policies for role `dashboard_user` for action `UPDATE`. Policies include `{\"Manage own buddies\",buddies_access_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_buddies_dashboard_user_UPDATE"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.session_participants` has multiple permissive policies for role `anon` for action `DELETE`. Policies include `{session_owner_all,session_participants_owner_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_session_participants_anon_DELETE"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.session_participants` has multiple permissive policies for role `anon` for action `INSERT`. Policies include `{session_owner_all,session_participants_owner_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_session_participants_anon_INSERT"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.session_participants` has multiple permissive policies for role `anon` for action `SELECT`. Policies include `{session_owner_all,session_participants_owner_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_session_participants_anon_SELECT"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.session_participants` has multiple permissive policies for role `anon` for action `UPDATE`. Policies include `{session_owner_all,session_participants_owner_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_session_participants_anon_UPDATE"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.session_participants` has multiple permissive policies for role `authenticated` for action `DELETE`. Policies include `{session_owner_all,session_participants_owner_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_session_participants_authenticated_DELETE"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.session_participants` has multiple permissive policies for role `authenticated` for action `INSERT`. Policies include `{session_owner_all,session_participants_owner_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_session_participants_authenticated_INSERT"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.session_participants` has multiple permissive policies for role `authenticated` for action `SELECT`. Policies include `{session_owner_all,session_participants_owner_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_session_participants_authenticated_SELECT"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.session_participants` has multiple permissive policies for role `authenticated` for action `UPDATE`. Policies include `{session_owner_all,session_participants_owner_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_session_participants_authenticated_UPDATE"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.session_participants` has multiple permissive policies for role `authenticator` for action `DELETE`. Policies include `{session_owner_all,session_participants_owner_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_session_participants_authenticator_DELETE"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.session_participants` has multiple permissive policies for role `authenticator` for action `INSERT`. Policies include `{session_owner_all,session_participants_owner_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_session_participants_authenticator_INSERT"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.session_participants` has multiple permissive policies for role `authenticator` for action `SELECT`. Policies include `{session_owner_all,session_participants_owner_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_session_participants_authenticator_SELECT"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.session_participants` has multiple permissive policies for role `authenticator` for action `UPDATE`. Policies include `{session_owner_all,session_participants_owner_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_session_participants_authenticator_UPDATE"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.session_participants` has multiple permissive policies for role `dashboard_user` for action `DELETE`. Policies include `{session_owner_all,session_participants_owner_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_session_participants_dashboard_user_DELETE"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.session_participants` has multiple permissive policies for role `dashboard_user` for action `INSERT`. Policies include `{session_owner_all,session_participants_owner_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_session_participants_dashboard_user_INSERT"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.session_participants` has multiple permissive policies for role `dashboard_user` for action `SELECT`. Policies include `{session_owner_all,session_participants_owner_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_session_participants_dashboard_user_SELECT"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.session_participants` has multiple permissive policies for role `dashboard_user` for action `UPDATE`. Policies include `{session_owner_all,session_participants_owner_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_session_participants_dashboard_user_UPDATE"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.tasting_sessions` has multiple permissive policies for role `anon` for action `DELETE`. Policies include `{\"Manage own sessions\",session_access_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_tasting_sessions_anon_DELETE"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.tasting_sessions` has multiple permissive policies for role `anon` for action `INSERT`. Policies include `{\"Manage own sessions\",session_access_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_tasting_sessions_anon_INSERT"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.tasting_sessions` has multiple permissive policies for role `anon` for action `SELECT`. Policies include `{\"Manage own sessions\",session_access_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_tasting_sessions_anon_SELECT"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.tasting_sessions` has multiple permissive policies for role `anon` for action `UPDATE`. Policies include `{\"Manage own sessions\",session_access_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_tasting_sessions_anon_UPDATE"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.tasting_sessions` has multiple permissive policies for role `authenticated` for action `DELETE`. Policies include `{\"Manage own sessions\",session_access_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_tasting_sessions_authenticated_DELETE"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.tasting_sessions` has multiple permissive policies for role `authenticated` for action `INSERT`. Policies include `{\"Manage own sessions\",session_access_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_tasting_sessions_authenticated_INSERT"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.tasting_sessions` has multiple permissive policies for role `authenticated` for action `SELECT`. Policies include `{\"Manage own sessions\",session_access_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_tasting_sessions_authenticated_SELECT"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.tasting_sessions` has multiple permissive policies for role `authenticated` for action `UPDATE`. Policies include `{\"Manage own sessions\",session_access_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_tasting_sessions_authenticated_UPDATE"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.tasting_sessions` has multiple permissive policies for role `authenticator` for action `DELETE`. Policies include `{\"Manage own sessions\",session_access_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_tasting_sessions_authenticator_DELETE"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.tasting_sessions` has multiple permissive policies for role `authenticator` for action `INSERT`. Policies include `{\"Manage own sessions\",session_access_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_tasting_sessions_authenticator_INSERT"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.tasting_sessions` has multiple permissive policies for role `authenticator` for action `SELECT`. Policies include `{\"Manage own sessions\",session_access_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_tasting_sessions_authenticator_SELECT"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.tasting_sessions` has multiple permissive policies for role `authenticator` for action `UPDATE`. Policies include `{\"Manage own sessions\",session_access_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_tasting_sessions_authenticator_UPDATE"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.tasting_sessions` has multiple permissive policies for role `dashboard_user` for action `DELETE`. Policies include `{\"Manage own sessions\",session_access_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_tasting_sessions_dashboard_user_DELETE"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.tasting_sessions` has multiple permissive policies for role `dashboard_user` for action `INSERT`. Policies include `{\"Manage own sessions\",session_access_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_tasting_sessions_dashboard_user_INSERT"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.tasting_sessions` has multiple permissive policies for role `dashboard_user` for action `SELECT`. Policies include `{\"Manage own sessions\",session_access_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_tasting_sessions_dashboard_user_SELECT"
},
{
"name": "multiple_permissive_policies",
"title": "Multiple Permissive Policies",
"level": "WARN",
"facing": "EXTERNAL",
"categories": "[\"PERFORMANCE\"]",
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
"detail": "Table `public.tasting_sessions` has multiple permissive policies for role `dashboard_user` for action `UPDATE`. Policies include `{\"Manage own sessions\",session_access_policy}`",
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
"cache_key": "multiple_permissive_policies_public_tasting_sessions_dashboard_user_UPDATE"
}
]

114
.nebiusbravetasks Normal file
View File

@@ -0,0 +1,114 @@
# IMPLEMENTIERUNGS-PLAN: Whisky Scanner App
## Stack: Next.js | Supabase | Nebius AI | Brave Search | Coolify
---
### 1. SICHERHEIT & INFRASTRUKTUR (Fundament)
**Ziel:** Sicherstellen, dass keine API-Keys leaken und keine User-Daten direkt an US-Anbieter fließen.
- [ ] **Environment Variablen säubern**
- Prüfe deine `.env` Datei.
- [ ] `NEBIUS_API_KEY`: Darf NICHT mit `NEXT_PUBLIC_` beginnen.
- [ ] `BRAVE_API_KEY`: Darf NICHT mit `NEXT_PUBLIC_` beginnen.
- [ ] `SUPABASE_SERVICE_ROLE_KEY`: Darf NICHT mit `NEXT_PUBLIC_` beginnen (für Admin-Schreibrechte).
- [ ] `NEXT_PUBLIC_SUPABASE_ANON_KEY`: Darf öffentlich sein (nur für RLS Lesezugriffe).
- [ ] **Server-Side Architektur erzwingen**
- Stelle sicher, dass alle Aufrufe zu Nebius und Brave ausschließlich über **Server Actions** (`'use server'`) oder **API Routes** (`app/api/...`) laufen.
- *Test:* Öffne die Entwicklertools im Browser (Netzwerk-Tab). Beim Scannen darf KEIN Aufruf zu `api.brave.com` oder `api.studio.nebius.ai` sichtbar sein. Nur Aufrufe zu deinem eigenen Backend (localhost/deine-domain).
- [ ] **Supabase Admin Client einrichten**
- Erstelle eine Utility-Datei (z.B. `lib/supabase-admin.ts`), die `createClient` mit dem `SUPABASE_SERVICE_ROLE_KEY` initialisiert.
- Nutze diesen Client NUR in Server Actions für das Schreiben in den globalen Cache.
---
### 2. NEBIUS AI STUDIO (Das "Auge")
**Ziel:** Bildanalyse und Extraktion strukturierter Daten für ca. 0,03 Cent pro Bild.
- [ ] **OpenAI SDK konfigurieren**
- Installiere das SDK: `npm install openai`
- Initialisiere den Client in `lib/ai-client.ts`:
```typescript
import { OpenAI } from "openai";
export const aiClient = new OpenAI({
baseURL: "[https://api.studio.nebius.ai/v1](https://api.studio.nebius.ai/v1)",
apiKey: process.env.NEBIUS_API_KEY,
});
```
- [ ] **Prompt Engineering (JSON Mode)**
- Erstelle den Prompt für das Modell `Qwen/Qwen2.5-VL-72B-Instruct` (oder Qwen2-VL-72B).
- Anweisung: "Extrahiere: Distillery, Name/Edition, Age/Vintage, ABV. Formatiere als reines JSON."
- WICHTIG: Setze `response_format: { type: "json_object" }` im API-Call, damit du valides JSON erhältst.
- [ ] **Such-Query Generierung**
- Lass die AI zusätzlich ein Feld `search_string` generieren.
- Format-Vorgabe an AI: `"site:whiskybase.com [Distillery] [Name] [Vintage]"`
- *Grund:* Das spart Logik im Code, die AI weiß am besten, was auf dem Etikett wichtig ist.
Bitte für den Adminuser einen Schalter zwischen Gemini und Nebius implementieren. Idealerweise im Magic Scan Formular.
---
### 3. BRAVE SEARCH API (Der "Notfall-Plan")
**Ziel:** Finden der Whiskybase-ID, wenn der Cache leer ist (Prepaid, keine Fixkosten).
- [ ] **Fetch-Funktion bauen**
- Erstelle eine Server-Funktion, die Brave aufruft.
- URL: `https://api.search.brave.com/res/v1/web/search?q=...`
- Header: `X-Subscription-Token: process.env.BRAVE_API_KEY`
- [ ] **Ergebnis-Parser implementieren**
- Nimm das erste Ergebnis (`results[0]`).
- Prüfe per Regex, ob der Link valide ist: `whiskybase.com/whiskies/whisky/([0-9]+)/...`
- Extrahiere die ID (z.B. "12345").
- [ ] **Fallback-Logik**
- Wenn Brave nichts findet (array leer), gib einen Fehler zurück oder markiere das Produkt als "Manuelle Prüfung nötig".
---
### 4. DATENBANK & CACHING (Die "Spar-Maschine")
**Ziel:** Suchkosten minimieren und Datenqualität sichern.
- [ ] **Datenbank-Schema (Tabelle `global_products`)**
- `id`: uuid (PK)
- `wb_id`: text (Whiskybase ID, unique)
- `full_name`: text (z.B. "Lagavulin 16")
- `search_vector`: tsvector (Für unscharfe Suche!)
- `image_hash`: text (Optional, falls du Hash-Vergleiche machst)
- `created_at`: timestamp
- [ ] **Row Level Security (RLS) Policies**
- Tabelle `global_products`:
- Policy "Enable Read Access for all users": `true` (für SELECT).
- Policy "Disable Insert/Update for users": `false` (für INSERT/UPDATE).
- *Grund:* Nur dein Server (Service Role) darf hier schreiben, um "Cache Poisoning" durch User zu verhindern.
- [ ] **Der "Main Loop" (Server Action)**
1. **AI:** Bild an Nebius -> JSON erhalten.
2. **DB Check:** Suche in `global_products` mit `.textSearch('search_vector', ai_name)`.
3. **Entscheidung:**
- *Treffer:* Nimm ID aus DB (Kosten: 0€). Return an Client.
- *Kein Treffer:* Rufe Brave Search auf (Kosten: 0,3 Cent).
4. **Cache Write:** Wenn Brave erfolgreich war, nutze `supabaseAdmin`, um den neuen Whisky in `global_products` zu speichern.
---
### 5. DSGVO / PRIVACY CHECKLISTE
**Ziel:** Rechtssicherer Betrieb in der EU.
- [ ] **Data Minimization**
- Schicke an Brave NUR den Suchstring (z.B. "Lagavulin 16").
- Schicke NIEMALS User-IDs, E-Mails oder IP-Adressen im Header oder Body an Brave.
- [ ] **Proxy-Check**
- Bestätige nochmals, dass Brave nur die IP deines Hetzner-Servers sieht, nie die des Endnutzers (siehe Punkt 1).
- [ ] **Nebius Region**
- Prüfe im Nebius Dashboard, dass dein Projekt in der Region **eu-north1** (Finnland) oder einer anderen EU-Region läuft, falls wählbar.

117
.tagidea Normal file
View File

@@ -0,0 +1,117 @@
--------------------------------------------------------------------------------
2. ERWEITERTES TAGGING-SYSTEM (NOSE, TASTE, FINISH)
--------------------------------------------------------------------------------
Das Ziel ist es, Textwüsten zu vermeiden und schnelles Erfassen zu ermöglichen. Das muss möglichst mobile friendly und easy für den Nutzer sein!
DATENBANK-LOGIK (SUPABASE EMPFEHLUNG):
Tabelle `tags`:
- id (uuid)
- name (string)
- category (enum: 'nose', 'taste', 'finish', 'texture')
- is_system_default (boolean) -> True = Admin Tag, False = User Custom Tag
- created_by (uuid, nullable) -> User ID, falls Custom Tag
FUNKTIONSWEISE:
1. Standard-Tags: Beim Erstellen einer Notiz werden die System-Tags als Chips vorgeschlagen (durchsuchbar). Bitte denke an i18n und übersetze die System-Tags bereits.
2. Custom Tags: Findet der User ein Aroma nicht, tippt er es ein -> Es wird als "User Tag" nur für ihn gespeichert.
3. Vorauswahl durch AI: Gemini könnte beim Scan basierend auf der Whisky-Datenbank schon Tags "pre-selecten" (highlighten).
--------------------------------------------------------------------------------
3. DIE "PROFUNDE" TAG-LISTE (MASTER LIST FÜR ADMIN PANEL)
--------------------------------------------------------------------------------
Diese Liste deckt das Spektrum von milden Lowlands bis zu schweren Islay Whiskys ab.
KATEGORIE A: FRUCHTIG (FRUITY)
- Apfel (Grüner Apfel / Bratapfel)
- Birne
- Zitrone / Zitrus
- Orange / Orangenschale
- Pfirsich / Aprikose
- Banane
- Ananas / Tropische Früchte
- Kirsche
- Beeren (Brombeere / Himbeere)
- Pflaume
- Trockenfrüchte (Rosinen / Datteln / Feigen) -> Typisch für Sherry-Fässer!
KATEGORIE B: SÜSS & CREMIG (SWEET & CREAMY)
- Vanille
- Honig
- Karamell / Toffee
- Schokolade (Zartbitter / Milch)
- Malz / Müsli
- Butter / Butterkeks
- Marzipan / Mandel
- Sahnebonbon
KATEGORIE C: WÜRZIG & NUSSIG (SPICY & NUTTY)
- Eiche (frisch / alt)
- Zimt
- Pfeffer (schwarz / weiß)
- Muskatnuss
- Ingwer
- Nelke
- Walnuss
- Haselnuss
- Geröstete Nüsse
KATEGORIE D: RAUCHIG & TORFIG (PEATY & SMOKY)
- Lagerfeuer / Holzkohle
- Torfrauch
- Asche
- Medizinisch (Jod / Verbandszeug) -> Typisch Laphroaig
- Teer / Asphalt
- Geräucherter Schinken / Speck
- Grillfleisch
KATEGORIE E: MARITIM & SALZIG (COASTAL)
- Meersalz / Salzlake
- Seetang / Algen
- Austern
- Frische Meeresbrise
KATEGORIE F: FLORAL & KRÄUTER (FLORAL & HERBAL)
- Heidekraut (Heather)
- Gras / Heu (frisch gemäht)
- Minze / Menthol
- Eukalyptus
- Tabak (frisch / Pfeife)
- Leder
- Tee (Schwarztee / Grüntee)
KATEGORIE G: FEHLNOTEN / SPEZIELLES (OFF-NOTES / SPECIAL)
- Schwefel (Streichholz)
- Gummi
- Seife (Lavendel)
- Klebstoff (Uhu) -> oft bei jungen Grain Whiskys
- Pilze / Waldboden (erdig)
--------------------------------------------------------------------------------
4. DAS FINISH (ABGANG) - SPEZIAL TAGS
--------------------------------------------------------------------------------
Beim Abgang geht es oft weniger um neuen Geschmack, sondern um Gefühl und Dauer.
DAUER:
- Kurz & Knackig
- Mittellang
- Lang anhaltend
- Ewig
TEXTUR & GEFÜHL (MOUTHFEEL):
- Ölig / Viskos
- Trocken (Adstringierend - zieht den Mund zusammen)
- Wärmend (Alkoholisch)
- Scharf / Beißend
- Weich / Samtig
- Wässrig
--------------------------------------------------------------------------------
5. ADMIN PANEL IDEEN
--------------------------------------------------------------------------------
Um diese Tags zu verwalten, brauchst du im Admin-Bereich (`/admin/tags`) folgende Features:
1. Bulk Import: Ein Script oder Textfeld, um obige Liste einmalig reinzuladen.
2. Tag Merging: Wenn 5 User "Apfelkuchen" als Custom Tag anlegen, kannst du entscheiden: "Okay, das wird ein offizieller System-Tag".
3. Farb-Codierung: Weise den Kategorien Farben zu (z.B. Frucht = Grün/Rot, Rauch = Grau, Süß = Gelb). Das macht die UI im Frontend viel schöner.
4. Synonym-Matching: (Advanced) Wenn User "Smoke" tippt, schlage "Rauch" vor.

110
.whiskytagweight Normal file
View File

@@ -0,0 +1,110 @@
Ich habe die Liste für dich überarbeitet und gewichtet. Wir gehen hier am besten zweigleisig vor:
Allgemeine Wahrscheinlichkeit: Wie oft kommt eine Note generell bei Whisky vor? (Vanille = sehr oft, Lavendel = sehr selten).
User-spezifische Sortierung (Tech-Tipp): In deiner Supabase-Query solltest du später die Tags nach usage_count (wie oft hat dieser User den Tag schon benutzt) sortieren.
Hier ist die aktualisierte Liste als Textdatei-Inhalt. Ich habe einen "Popularity Score" (1-5) hinzugefügt.
5 = Fast immer dabei (Standard-Aromen) -> Diese müssen ganz oben stehen.
1 = Exoten / Fehlnoten -> Diese können weiter unten in der Liste auftauchen.
Hier ist der Datei-Inhalt für deinen Import:
# WHISKY TAGS - GEWICHTET NACH HÄUFIGKEIT (1-5)
# 5 = Sehr häufig (Top Level)
# 1 = Sehr selten (Nische)
--------------------------------------------------------------------------------
KATEGORIE: SÜSS & FASS (SWEET & CASK)
--------------------------------------------------------------------------------
(5) Vanille # Der Klassiker (Ex-Bourbon Fässer)
(5) Eiche (würzig) # Basis fast aller Whiskys
(5) Karamell / Toffee # Durch Fassausbrennung
(4) Honig # Sehr häufig bei Speyside/Highland
(4) Malz / Getreide # Der Rohstoff selbst
(3) Schokolade (Dunkel) # Oft bei Sherry-Fässern
(3) Nüsse (Allgemein) # Oxidative Note
(2) Marzipan # Eher spezifisch
(2) Butterkeks # Typisch für junge Malts
(1) Kokosnuss # Speziell (oft bei alter US-Eiche)
--------------------------------------------------------------------------------
KATEGORIE: FRUCHT (FRUITY)
--------------------------------------------------------------------------------
(5) Zitrus / Zitrone # Frische Kopfnote
(4) Apfel (Grün/Rot) # Standard Ester-Note
(4) Birne # Typisch für Glenfiddich etc.
(4) Trockenfrüchte # Rosinen/Sultaninen (Sherry-Einfluss)
(3) Orange / Schale # Häufig
(3) Beeren (Dunkel) # Brombeere/Johannisbeere
(2) Pfirsich / Aprikose # Steinobst
(2) Banane # Spezielle Hefe-Note (z.B. Jack Daniels)
(2) Pflaume # Reife Note
(1) Ananas / Tropisch # Eher bei alten Malts / Iren
(1) Melone # Selten
--------------------------------------------------------------------------------
KATEGORIE: WÜRZE & KRÄUTER (SPICE & HERBAL)
--------------------------------------------------------------------------------
(4) Pfeffer (Schwarz) # Der "Biss" (Talisker)
(4) Zimt # Weihnachtsgewürze (Sherry/Virgin Oak)
(3) Ingwer # Schärfe & Frische
(3) Gras / Heu # Lowland Stil / Junger Whisky
(2) Muskatnuss # Holzwürze
(2) Minze / Menthol # Oft im Finish
(2) Nelke # Intensiv würzig
(1) Eukalyptus # Sehr frisch
(1) Anis / Lakritz # Speziell
--------------------------------------------------------------------------------
KATEGORIE: RAUCH & MARITIM (SMOKE & COASTAL)
--------------------------------------------------------------------------------
# Achtung: Diese sind bei Islay-Fans eine (5), global gesehen eher eine (3).
# Ich gewichte sie hier hoch, da "Kenner" sie oft suchen.
(4) Torfrauch # Der Klassiker
(4) Lagerfeuer # Holzrauch
(3) Meersalz / Brine # Küsten-Whiskys
(3) Asche # Trockener Rauch
(3) Speck / Schinken # Fleischiger Rauch
(2) Medizinisch / Jod # Laphroaig-Style
(2) Teer # Schwerer Rauch
(1) Seetang / Alge # Sehr speziell
(1) Gummistiefel # Industrie-Note
--------------------------------------------------------------------------------
KATEGORIE: TEXTUR & FINISH (FEELING)
--------------------------------------------------------------------------------
(5) Wärmend # Alkohol-Wirkung
(4) Trocken # Adstringierend (Eiche)
(4) Lang anhaltend # Qualitätsmerkmal
(3) Ölig / Cremig # Mundgefühl
(3) Würzig # Prickeln
(2) Kurz # Eher negativ / leicht
(1) Wässrig # Negativ
--------------------------------------------------------------------------------
KATEGORIE: SPEZIELLES / OFF-NOTES
--------------------------------------------------------------------------------
(2) Leder # Alter Whisky / Sherry
(2) Tabak # Alter Whisky
(2) Schwefel # Kann Fehlrnote sein, oder gewollt "dreckig"
(1) Pilze / Erdig # Dunnage Warehouse Funk
(1) Seife # Oft als Fehler wahrgenommen
(1) Metallisch # Fehler bei der Destillation
Tipp zur Implementierung der "Smart Sorting" Logik
Damit der User sofort das Richtige findet, würde ich im Frontend (oder in der API) eine kombinierte Sortierung bauen.
Stell dir vor, Gemini hat erkannt: "Das ist ein Ardbeg 10". Dann weiß dein System (via Gemini oder Datenbank): Kategorie = Islay / Rauchig.
Deine Sortierlogik für die Tags sollte dann so aussehen:
Priorität A (Der "AI-Kontext"): Wenn Gemini sagt "Likely Peated", dann booste alle Tags aus der Kategorie "RAUCH" temporär nach ganz oben. User sieht sofort: Torf, Rauch, Asche.
Priorität B (Global Weight): Danach kommen die Tags mit Score 5 und 4 (Vanille, Eiche, Apfel). User sieht die Standards.
Priorität C (Der Rest): Ganz unten kommen die Exoten (Ananas, Seife).

View File

@@ -0,0 +1,17 @@
-- Incremental Migration for Tag Popularity Scores
ALTER TABLE tags ADD COLUMN IF NOT EXISTS popularity_score INTEGER DEFAULT 3;
-- Update scores for common aromas (5)
UPDATE tags SET popularity_score = 5 WHERE name IN ('Vanille', 'Eiche', 'Karamell', 'Zitrone', 'Zitrus', 'Wärmend');
-- Update scores for frequent aromas (4)
UPDATE tags SET popularity_score = 4 WHERE name IN ('Honig', 'Malz', 'Apfel', 'Grüner Apfel', 'Bratapfel', 'Birne', 'Trockenfrüchte', 'Pfeffer', 'Zimt', 'Torfrauch', 'Lagerfeuer', 'Trocken', 'Lang anhaltend');
-- Update scores for regular aromas (3)
UPDATE tags SET popularity_score = 3 WHERE name IN ('Schokolade', 'Zartbitterschokolade', 'Milchschokolade', 'Nüsse', 'Orange', 'Orangenschale', 'Beeren', 'Brombeere', 'Himbeere', 'Ingwer', 'Gras', 'Heu', 'Meersalz', 'Salzlake', 'Asche', 'Speck', 'Geräucherter Schinken', 'Grillfleisch', 'Ölig', 'Viskos', 'Würzig');
-- Update scores for specific aromas (2)
UPDATE tags SET popularity_score = 2 WHERE name IN ('Marzipan', 'Butterkeks', 'Pfirsich', 'Aprikose', 'Banane', 'Pflaume', 'Muskatnuss', 'Minze', 'Menthol', 'Nelke', 'Medizinisch', 'Jod', 'Teer', 'Asphalt', 'Leder', 'Tabak', 'Schwefel', 'Kurz');
-- Update scores for rare/exotic aromas (1)
UPDATE tags SET popularity_score = 1 WHERE name IN ('Kokosnuss', 'Ananas', 'Tropische Früchte', 'Melone', 'Eukalyptus', 'Seetang', 'Algen', 'Austern', 'Wässrig', 'Pilze', 'Seife', 'Metallisch');

29
global_products.sql Normal file
View File

@@ -0,0 +1,29 @@
-- Global Products for caching searches
CREATE TABLE IF NOT EXISTS global_products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
wb_id TEXT UNIQUE NOT NULL,
full_name TEXT NOT NULL,
search_vector tsvector GENERATED ALWAYS AS (to_tsvector('simple', full_name)) STORED,
image_hash TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now())
);
-- Index for search vector
CREATE INDEX IF NOT EXISTS idx_global_products_search_vector ON global_products USING GIN (search_vector);
-- Enable RLS
ALTER TABLE global_products ENABLE ROW LEVEL SECURITY;
-- Policies for global_products
-- Enable Read Access for all users (SELECT)
DROP POLICY IF EXISTS "Enable Read Access for all users" ON global_products;
CREATE POLICY "Enable Read Access for all users"
ON global_products FOR SELECT
USING (true);
-- Disable Insert/Update for normal users (only Service Role/Admins)
DROP POLICY IF EXISTS "Enable Admin Insert/Update" ON global_products;
CREATE POLICY "Enable Admin Insert/Update"
ON global_products FOR ALL
USING (EXISTS (SELECT 1 FROM admin_users WHERE user_id = (SELECT auth.uid())))
WITH CHECK (EXISTS (SELECT 1 FROM admin_users WHERE user_id = (SELECT auth.uid())));

View File

@@ -19,6 +19,7 @@
"heic2any": "^0.0.4", "heic2any": "^0.0.4",
"lucide-react": "^0.300.0", "lucide-react": "^0.300.0",
"next": "14.2.23", "next": "14.2.23",
"openai": "^6.15.0",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"sharp": "^0.34.5", "sharp": "^0.34.5",

36
pnpm-lock.yaml generated
View File

@@ -32,6 +32,9 @@ importers:
next: next:
specifier: 14.2.23 specifier: 14.2.23
version: 14.2.23(@babel/core@7.28.5)(@playwright/test@1.57.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 14.2.23(@babel/core@7.28.5)(@playwright/test@1.57.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
openai:
specifier: ^6.15.0
version: 6.15.0(ws@8.18.3)(zod@3.25.76)
react: react:
specifier: ^18 specifier: ^18
version: 18.3.1 version: 18.3.1
@@ -2233,6 +2236,18 @@ packages:
once@1.4.0: once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
openai@6.15.0:
resolution: {integrity: sha512-F1Lvs5BoVvmZtzkUEVyh8mDQPPFolq4F+xdsx/DO8Hee8YF3IGAlZqUIsF+DVGhqf4aU0a3bTghsxB6OIsRy1g==}
hasBin: true
peerDependencies:
ws: ^8.18.0
zod: ^3.25 || ^4.0
peerDependenciesMeta:
ws:
optional: true
zod:
optional: true
optionator@0.9.4: optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -4357,8 +4372,8 @@ snapshots:
'@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.9.3) '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.9.3)
eslint: 8.57.1 eslint: 8.57.1
eslint-import-resolver-node: 0.3.9 eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
eslint-plugin-react: 7.37.5(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1)
eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1) eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1)
@@ -4377,7 +4392,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1):
dependencies: dependencies:
'@nolyfill/is-core-module': 1.0.39 '@nolyfill/is-core-module': 1.0.39
debug: 4.4.3 debug: 4.4.3
@@ -4388,22 +4403,22 @@ snapshots:
tinyglobby: 0.2.15 tinyglobby: 0.2.15
unrs-resolver: 1.11.1 unrs-resolver: 1.11.1
optionalDependencies: optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): eslint-module-utils@2.12.1(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
dependencies: dependencies:
debug: 3.2.7 debug: 3.2.7
optionalDependencies: optionalDependencies:
'@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.9.3) '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.9.3)
eslint: 8.57.1 eslint: 8.57.1
eslint-import-resolver-node: 0.3.9 eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
dependencies: dependencies:
'@rtsao/scc': 1.1.0 '@rtsao/scc': 1.1.0
array-includes: 3.1.9 array-includes: 3.1.9
@@ -4414,7 +4429,7 @@ snapshots:
doctrine: 2.1.0 doctrine: 2.1.0
eslint: 8.57.1 eslint: 8.57.1
eslint-import-resolver-node: 0.3.9 eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.1(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) eslint-module-utils: 2.12.1(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
hasown: 2.0.2 hasown: 2.0.2
is-core-module: 2.16.1 is-core-module: 2.16.1
is-glob: 4.0.3 is-glob: 4.0.3
@@ -5152,6 +5167,11 @@ snapshots:
dependencies: dependencies:
wrappy: 1.0.2 wrappy: 1.0.2
openai@6.15.0(ws@8.18.3)(zod@3.25.76):
optionalDependencies:
ws: 8.18.3
zod: 3.25.76
optionator@0.9.4: optionator@0.9.4:
dependencies: dependencies:
deep-is: 0.1.4 deep-is: 0.1.4

View File

@@ -100,6 +100,12 @@ export default async function AdminPage() {
> >
Manage Plans Manage Plans
</Link> </Link>
<Link
href="/admin/tags"
className="px-4 py-2 bg-pink-600 hover:bg-pink-700 text-white rounded-xl font-bold transition-colors"
>
Manage Tags
</Link>
<Link <Link
href="/admin/users" href="/admin/users"
className="px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded-xl font-bold transition-colors" className="px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded-xl font-bold transition-colors"

196
src/app/admin/tags/page.tsx Normal file
View File

@@ -0,0 +1,196 @@
'use client';
import React, { useState, useEffect } from 'react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import Link from 'next/link';
import { ChevronLeft, Tag as TagIcon, Plus, Search, Trash2, Shield, User, Filter, Download } from 'lucide-react';
import { Tag, TagCategory, getTagsByCategory } from '@/services/tags';
import { useI18n } from '@/i18n/I18nContext';
export default function AdminTagsPage() {
const { t } = useI18n();
const supabase = createClientComponentClient();
const [tags, setTags] = useState<Tag[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [search, setSearch] = useState('');
const [categoryFilter, setCategoryFilter] = useState<TagCategory | 'all'>('all');
useEffect(() => {
fetchTags();
}, []);
const fetchTags = async () => {
setIsLoading(true);
const { data, error } = await supabase
.from('tags')
.select('*')
.order('popularity_score', { ascending: false })
.order('name');
if (data) setTags(data);
setIsLoading(false);
};
const filteredTags = tags.filter(tag => {
const matchesSearch = tag.name.toLowerCase().includes(search.toLowerCase());
const matchesCategory = categoryFilter === 'all' || tag.category === categoryFilter;
return matchesSearch && matchesCategory;
});
const handleDelete = async (id: string) => {
if (!confirm('Tag wirklich löschen?')) return;
const { error } = await supabase.from('tags').delete().eq('id', id);
if (!error) setTags(prev => prev.filter(t => t.id !== id));
};
const toggleSystemDefault = async (tag: Tag) => {
const { error } = await supabase
.from('tags')
.update({ is_system_default: !tag.is_system_default })
.eq('id', tag.id);
if (!error) {
setTags(prev => prev.map(t => t.id === tag.id ? { ...t, is_system_default: !t.is_system_default } : t));
}
};
const updatePopularity = async (tagId: string, score: number) => {
const { error } = await supabase
.from('tags')
.update({ popularity_score: score })
.eq('id', tagId);
if (!error) {
setTags(prev => prev.map(t => t.id === tagId ? { ...t, popularity_score: score } : t));
}
};
return (
<main className="min-h-screen bg-zinc-50 dark:bg-black p-4 md:p-12">
<div className="max-w-6xl mx-auto space-y-8">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<Link href="/admin" className="text-zinc-500 hover:text-amber-600 transition-colors flex items-center gap-2 text-sm font-bold mb-2">
<ChevronLeft size={16} /> Admin Dashboard
</Link>
<h1 className="text-3xl font-black text-zinc-900 dark:text-white tracking-tighter flex items-center gap-3">
<TagIcon className="text-amber-600" /> Aroma Tags verwalten
</h1>
</div>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-3xl border border-zinc-200 dark:border-zinc-800 shadow-xl overflow-hidden">
<div className="p-6 border-b border-zinc-200 dark:border-zinc-800 bg-zinc-50/50 dark:bg-zinc-900/50 flex flex-col md:flex-row gap-4 justify-between">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={18} />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Tags suchen..."
className="w-full pl-10 pr-4 py-2 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all dark:text-zinc-200"
/>
</div>
<div className="flex items-center gap-2">
<Filter size={18} className="text-zinc-400" />
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value as any)}
className="bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl px-3 py-2 text-sm font-bold uppercase tracking-tight outline-none focus:ring-2 focus:ring-amber-500 dark:text-zinc-200"
>
<option value="all">Alle Kategorien</option>
<option value="nose">Nose</option>
<option value="taste">Taste</option>
<option value="finish">Finish</option>
<option value="texture">Texture</option>
</select>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="text-[10px] font-black text-zinc-400 uppercase tracking-[0.15em] border-b border-zinc-100 dark:border-zinc-800">
<th className="px-6 py-4">Name</th>
<th className="px-6 py-4">Kategorie</th>
<th className="px-6 py-4">Popularität</th>
<th className="px-6 py-4">Typ</th>
<th className="px-6 py-4 text-right">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
{isLoading ? (
<tr>
<td colSpan={4} className="px-6 py-12 text-center text-zinc-400 italic">Lade Tags...</td>
</tr>
) : filteredTags.length === 0 ? (
<tr>
<td colSpan={4} className="px-6 py-12 text-center text-zinc-400 italic">Keine Tags gefunden.</td>
</tr>
) : (
filteredTags.map((tag) => (
<tr key={tag.id} className="hover:bg-zinc-50 dark:hover:bg-zinc-800/30 transition-colors group">
<td className="px-6 py-4">
<div className="font-bold text-zinc-900 dark:text-zinc-100">
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
</div>
{tag.is_system_default && (
<div className="text-[10px] text-zinc-400 font-mono">{tag.name} (Key)</div>
)}
</td>
<td className="px-6 py-4">
<span className={`text-[10px] font-black px-2 py-1 rounded-lg uppercase tracking-widest ${tag.category === 'nose' ? 'bg-green-100 text-green-700' :
tag.category === 'taste' ? 'bg-blue-100 text-blue-700' :
tag.category === 'finish' ? 'bg-amber-100 text-amber-700' :
'bg-zinc-100 text-zinc-700'
}`}>
{tag.category}
</span>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map((score) => (
<button
key={score}
onClick={() => updatePopularity(tag.id, score)}
className={`w-6 h-6 rounded-md flex items-center justify-center text-[10px] font-black transition-all ${tag.popularity_score === score
? 'bg-amber-600 text-white shadow-sm'
: 'bg-zinc-100 text-zinc-400 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700'
}`}
>
{score}
</button>
))}
</div>
</td>
<td className="px-6 py-4">
<button
onClick={() => toggleSystemDefault(tag)}
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all ${tag.is_system_default
? 'bg-amber-600 text-white'
: 'bg-zinc-100 text-zinc-400 dark:bg-zinc-800 hover:bg-zinc-200'
}`}
>
{tag.is_system_default ? <Shield size={10} /> : <User size={10} />}
{tag.is_system_default ? 'System' : 'Custom'}
</button>
</td>
<td className="px-6 py-4 text-right">
<button
onClick={() => handleDelete(tag.id)}
className="p-2 text-zinc-400 hover:text-red-600 transition-colors opacity-0 group-hover:opacity-100"
>
<Trash2 size={16} />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</main>
);
}

View File

@@ -2,10 +2,9 @@ import { createServerComponentClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { ChevronLeft, Calendar, Award, Droplets, MapPin, Tag, ExternalLink, Package, PlusCircle } from 'lucide-react'; import { ChevronLeft, Calendar, Award, Droplets, MapPin, Tag, ExternalLink, Package, PlusCircle, Info } from 'lucide-react';
import { getStorageUrl } from '@/lib/supabase'; import { getStorageUrl } from '@/lib/supabase';
import TastingNoteForm from '@/components/TastingNoteForm'; import TastingNoteForm from '@/components/TastingNoteForm';
import StatusSwitcher from '@/components/StatusSwitcher';
import TastingList from '@/components/TastingList'; import TastingList from '@/components/TastingList';
import DeleteBottleButton from '@/components/DeleteBottleButton'; import DeleteBottleButton from '@/components/DeleteBottleButton';
import EditBottleForm from '@/components/EditBottleForm'; import EditBottleForm from '@/components/EditBottleForm';
@@ -48,11 +47,19 @@ export default async function BottlePage({
id, id,
name name
), ),
tasting_tags ( tasting_buddies (
buddies ( buddies (
id, id,
name name
) )
),
tasting_tags (
tags (
id,
name,
category,
is_system_default
)
) )
`) `)
.eq('bottle_id', params.id) .eq('bottle_id', params.id)
@@ -68,9 +75,11 @@ export default async function BottlePage({
.select(` .select(`
*, *,
tasting_tags ( tasting_tags (
buddies ( tags (
id, id,
name name,
category,
is_system_default
) )
) )
`) `)
@@ -125,32 +134,60 @@ export default async function BottlePage({
)} )}
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm flex flex-col justify-between"> <div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm flex flex-col justify-between">
<div> <div>
<div className="flex items-center gap-2 text-zinc-400 text-xs font-bold uppercase mb-1"> <div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
<Tag size={14} /> Kategorie <Tag size={12} /> Kategorie
</div> </div>
<div className="font-semibold dark:text-zinc-200">{bottle.category || '-'}</div> <div className="font-bold text-sm dark:text-zinc-200">{bottle.category || '-'}</div>
</div> </div>
</div> </div>
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm"> <div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm">
<div className="flex items-center gap-2 text-zinc-400 text-xs font-bold uppercase mb-1"> <div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
<Droplets size={14} /> Alkoholgehalt <Droplets size={12} /> Alkoholgehalt
</div> </div>
<div className="font-semibold dark:text-zinc-200">{bottle.abv}% Vol.</div> <div className="font-bold text-sm dark:text-zinc-200">{bottle.abv}% Vol.</div>
</div> </div>
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm"> <div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm">
<div className="flex items-center gap-2 text-zinc-400 text-xs font-bold uppercase mb-1"> <div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
<Award size={14} /> Alter <Award size={12} /> Alter
</div> </div>
<div className="font-semibold dark:text-zinc-200">{bottle.age ? `${bottle.age} Jahre` : '-'}</div> <div className="font-bold text-sm dark:text-zinc-200">{bottle.age ? `${bottle.age} J.` : '-'}</div>
</div> </div>
{bottle.distilled_at && (
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm"> <div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm">
<div className="flex items-center gap-2 text-zinc-400 text-xs font-bold uppercase mb-1"> <div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
<Calendar size={14} /> Zuletzt verkostet <Calendar size={12} /> Destilliert
</div> </div>
<div className="font-semibold dark:text-zinc-200"> <div className="font-bold text-sm dark:text-zinc-200">{bottle.distilled_at}</div>
</div>
)}
{bottle.bottled_at && (
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm">
<div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
<Package size={12} /> Abgefüllt
</div>
<div className="font-bold text-sm dark:text-zinc-200">{bottle.bottled_at}</div>
</div>
)}
{bottle.batch_info && (
<div className="p-4 bg-zinc-50 dark:bg-zinc-800/30 rounded-2xl border border-dashed border-zinc-200 dark:border-zinc-700/50 md:col-span-1">
<div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
<Info size={12} /> Batch / Code
</div>
<div className="font-mono text-xs dark:text-zinc-300 truncate" title={bottle.batch_info}>{bottle.batch_info}</div>
</div>
)}
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm">
<div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
<Calendar size={12} /> Letzter Dram
</div>
<div className="font-bold text-sm dark:text-zinc-200">
{tastings && tastings.length > 0 {tastings && tastings.length > 0
? new Date(tastings[0].created_at).toLocaleDateString('de-DE') ? new Date(tastings[0].created_at).toLocaleDateString('de-DE')
: 'Noch nie'} : 'Noch nie'}
@@ -158,8 +195,7 @@ export default async function BottlePage({
</div> </div>
</div> </div>
<div className="pt-2 space-y-4"> <div className="pt-2 flex flex-wrap gap-4">
<StatusSwitcher bottleId={bottle.id} currentStatus={bottle.status} />
<EditBottleForm bottle={bottle} /> <EditBottleForm bottle={bottle} />
<DeleteBottleButton bottleId={bottle.id} /> <DeleteBottleButton bottleId={bottle.id} />
</div> </div>

View File

@@ -2,7 +2,7 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Search, Filter, X, Calendar, Clock, Package, Lock, Unlock, Ghost, FlaskConical, AlertCircle, Trash2, AlertTriangle, PlusCircle } from 'lucide-react'; import { Search, Filter, X, Calendar, Clock, Package, FlaskConical, AlertCircle, Trash2, AlertTriangle, PlusCircle } from 'lucide-react';
import { getStorageUrl } from '@/lib/supabase'; import { getStorageUrl } from '@/lib/supabase';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { validateSession } from '@/services/validate-session'; import { validateSession } from '@/services/validate-session';
@@ -19,7 +19,6 @@ interface Bottle {
age: number; age: number;
image_url: string; image_url: string;
purchase_price?: number | null; purchase_price?: number | null;
status: 'sealed' | 'open' | 'sampled' | 'empty';
created_at: string; created_at: string;
last_tasted?: string | null; last_tasted?: string | null;
is_whisky?: boolean; is_whisky?: boolean;
@@ -33,15 +32,6 @@ interface BottleCardProps {
function BottleCard({ bottle, sessionId }: BottleCardProps) { function BottleCard({ bottle, sessionId }: BottleCardProps) {
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const statusConfig = {
open: { icon: Unlock, color: 'bg-amber-500/80 border-amber-400/50', label: t('bottle.status.open') },
sampled: { icon: FlaskConical, color: 'bg-purple-500/80 border-purple-400/50', label: t('bottle.status.sampled') },
empty: { icon: Ghost, color: 'bg-zinc-500/80 border-zinc-400/50', label: t('bottle.status.empty') },
sealed: { icon: Lock, color: 'bg-blue-600/80 border-blue-400/50', label: t('bottle.status.sealed') },
};
const StatusIcon = statusConfig[bottle.status as keyof typeof statusConfig]?.icon || Lock;
const statusStyle = statusConfig[bottle.status as keyof typeof statusConfig] || statusConfig.sealed;
return ( return (
<Link <Link
@@ -71,10 +61,6 @@ function BottleCard({ bottle, sessionId }: BottleCardProps) {
</div> </div>
)} )}
<div className={`absolute bottom-3 left-3 px-3 py-1.5 rounded-xl text-[10px] font-black uppercase flex items-center gap-2 backdrop-blur-md border shadow-lg ${statusStyle.color}`}>
<StatusIcon size={12} />
{statusStyle.label}
</div>
</div> </div>
<div className="p-3 md:p-5 space-y-3 md:space-y-4"> <div className="p-3 md:p-5 space-y-3 md:space-y-4">
@@ -144,7 +130,6 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string | null>(null); const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [selectedDistillery, setSelectedDistillery] = useState<string | null>(null); const [selectedDistillery, setSelectedDistillery] = useState<string | null>(null);
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
const [sortBy, setSortBy] = useState<'name' | 'last_tasted' | 'created_at'>('created_at'); const [sortBy, setSortBy] = useState<'name' | 'last_tasted' | 'created_at'>('created_at');
const categories = useMemo(() => { const categories = useMemo(() => {
@@ -174,9 +159,8 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
const matchesCategory = !selectedCategory || bottle.category === selectedCategory; const matchesCategory = !selectedCategory || bottle.category === selectedCategory;
const matchesDistillery = !selectedDistillery || bottle.distillery === selectedDistillery; const matchesDistillery = !selectedDistillery || bottle.distillery === selectedDistillery;
const matchesStatus = !selectedStatus || bottle.status === selectedStatus;
return matchesSearch && matchesCategory && matchesDistillery && matchesStatus; return matchesSearch && matchesCategory && matchesDistillery;
}); });
// Sorting logic // Sorting logic
@@ -191,7 +175,7 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
} }
}); });
}, [bottles, searchQuery, selectedCategory, selectedDistillery, selectedStatus, sortBy]); }, [bottles, searchQuery, selectedCategory, selectedDistillery, sortBy]);
const [isFiltersOpen, setIsFiltersOpen] = useState(false); const [isFiltersOpen, setIsFiltersOpen] = useState(false);
@@ -203,7 +187,7 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
); );
} }
const activeFiltersCount = (selectedCategory ? 1 : 0) + (selectedDistillery ? 1 : 0) + (selectedStatus ? 1 : 0); const activeFiltersCount = (selectedCategory ? 1 : 0) + (selectedDistillery ? 1 : 0);
return ( return (
<div className="w-full space-y-8"> <div className="w-full space-y-8">
@@ -314,23 +298,6 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
</div> </div>
</div> </div>
<div className="space-y-3">
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400 px-1">{t('grid.filter.status')}</label>
<div className="flex flex-wrap gap-2">
{['sealed', 'open', 'sampled', 'empty'].map((status) => (
<button
key={status}
onClick={() => setSelectedStatus(selectedStatus === status ? null : status)}
className={`px-3 py-1.5 rounded-xl text-[10px] font-black transition-all border ${selectedStatus === status
? status === 'open' ? 'bg-amber-500 border-amber-500 text-white' : status === 'sampled' ? 'bg-purple-500 border-purple-500 text-white' : status === 'empty' ? 'bg-zinc-500 border-zinc-500 text-white' : 'bg-blue-600 border-blue-600 text-white'
: 'bg-zinc-50 dark:bg-zinc-800 border-zinc-200 dark:border-zinc-700 text-zinc-500'
}`}
>
{t(`bottle.status.${status}`).toUpperCase()}
</button>
))}
</div>
</div>
</div> </div>
<div className="pt-4 border-t border-zinc-100 dark:border-zinc-800 flex justify-between items-center"> <div className="pt-4 border-t border-zinc-100 dark:border-zinc-800 flex justify-between items-center">
@@ -338,7 +305,6 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
onClick={() => { onClick={() => {
setSelectedCategory(null); setSelectedCategory(null);
setSelectedDistillery(null); setSelectedDistillery(null);
setSelectedStatus(null);
setSearchQuery(''); setSearchQuery('');
}} }}
className="text-[10px] font-black text-red-500 uppercase tracking-widest hover:underline" className="text-[10px] font-black text-red-500 uppercase tracking-widest hover:underline"

View File

@@ -1,7 +1,8 @@
'use client'; 'use client';
import React, { useRef, useState } from 'react'; import React, { useRef, useState } from 'react';
import { Camera, Upload, CheckCircle2, AlertCircle, X, Search, ExternalLink, ArrowRight, Loader2, Wand2, Plus, Sparkles, Droplets, ChevronRight } from 'lucide-react'; import { Camera, Upload, CheckCircle2, AlertCircle, X, Search, ExternalLink, ArrowRight, Loader2, Wand2, Plus, Sparkles, Droplets, ChevronRight, User } from 'lucide-react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'; import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { analyzeBottle } from '@/services/analyze-bottle'; import { analyzeBottle } from '@/services/analyze-bottle';
@@ -17,7 +18,7 @@ import Link from 'next/link';
import { useI18n } from '@/i18n/I18nContext'; import { useI18n } from '@/i18n/I18nContext';
import { useSession } from '@/context/SessionContext'; import { useSession } from '@/context/SessionContext';
import { shortenCategory } from '@/lib/format'; import { shortenCategory } from '@/lib/format';
import { magicScan } from '@/services/magic-scan';
interface CameraCaptureProps { interface CameraCaptureProps {
onImageCaptured?: (base64Image: string) => void; onImageCaptured?: (base64Image: string) => void;
onAnalysisComplete?: (data: BottleMetadata) => void; onAnalysisComplete?: (data: BottleMetadata) => void;
@@ -63,6 +64,23 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
const [wbDiscovery, setWbDiscovery] = useState<{ id: string; url: string; title: string } | null>(null); const [wbDiscovery, setWbDiscovery] = useState<{ id: string; url: string; title: string } | null>(null);
const [isDiscovering, setIsDiscovering] = useState(false); const [isDiscovering, setIsDiscovering] = useState(false);
const [originalFile, setOriginalFile] = useState<File | null>(null); const [originalFile, setOriginalFile] = useState<File | null>(null);
const [isAdmin, setIsAdmin] = useState(false);
const [aiProvider, setAiProvider] = useState<'gemini' | 'nebius'>('gemini');
React.useEffect(() => {
const checkAdmin = async () => {
const { data: { user } } = await supabase.auth.getUser();
if (user) {
const { data } = await supabase
.from('admin_users')
.select('role')
.eq('user_id', user.id)
.maybeSingle();
setIsAdmin(!!data);
}
};
checkAdmin();
}, [supabase]);
const handleCapture = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleCapture = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
@@ -116,11 +134,19 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
return; return;
} }
const response = await analyzeBottle(compressedBase64); const response = await magicScan(compressedBase64, aiProvider);
if (response.success && response.data) { if (response.success && response.data) {
setAnalysisResult(response.data); setAnalysisResult(response.data);
if (response.wb_id) {
setWbDiscovery({
id: response.wb_id,
url: `https://www.whiskybase.com/whiskies/whisky/${response.wb_id}`,
title: `${response.data.distillery || ''} ${response.data.name || ''}`
});
}
// Duplicate Check // Duplicate Check
const match = await findMatchingBottle(response.data); const match = await findMatchingBottle(response.data);
if (match) { if (match) {
@@ -299,8 +325,27 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
return ( return (
<div className="flex flex-col items-center gap-4 md:gap-6 w-full max-w-md mx-auto p-4 md:p-6 bg-white dark:bg-zinc-900 rounded-3xl shadow-2xl border border-zinc-200 dark:border-zinc-800 transition-all hover:shadow-whisky-amber/20"> <div className="flex flex-col items-center gap-4 md:gap-6 w-full max-w-md mx-auto p-4 md:p-6 bg-white dark:bg-zinc-900 rounded-3xl shadow-2xl border border-zinc-200 dark:border-zinc-800 transition-all hover:shadow-whisky-amber/20">
<div className="flex items-center justify-between w-full">
<h2 className="text-xl md:text-2xl font-bold text-zinc-800 dark:text-zinc-100 italic">{t('camera.magicShot')}</h2> <h2 className="text-xl md:text-2xl font-bold text-zinc-800 dark:text-zinc-100 italic">{t('camera.magicShot')}</h2>
{isAdmin && (
<div className="flex items-center gap-1 bg-zinc-100 dark:bg-zinc-800 p-1 rounded-xl border border-zinc-200 dark:border-zinc-700">
<button
onClick={() => setAiProvider('gemini')}
className={`px-3 py-1 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all ${aiProvider === 'gemini' ? 'bg-white dark:bg-zinc-700 text-amber-600 shadow-sm' : 'text-zinc-400'}`}
>
Gemini
</button>
<button
onClick={() => setAiProvider('nebius')}
className={`px-3 py-1 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all ${aiProvider === 'nebius' ? 'bg-white dark:bg-zinc-700 text-amber-600 shadow-sm' : 'text-zinc-400'}`}
>
Nebius
</button>
</div>
)}
</div>
<div <div
className="relative group cursor-pointer w-full aspect-square rounded-2xl border-2 border-dashed border-zinc-300 dark:border-zinc-700 overflow-hidden flex items-center justify-center bg-zinc-50 dark:bg-zinc-800/50 hover:border-amber-500 transition-colors" className="relative group cursor-pointer w-full aspect-square rounded-2xl border-2 border-dashed border-zinc-300 dark:border-zinc-700 overflow-hidden flex items-center justify-center bg-zinc-50 dark:bg-zinc-800/50 hover:border-amber-500 transition-colors"
onClick={triggerUpload} onClick={triggerUpload}

View File

@@ -7,7 +7,6 @@ import { useI18n } from '@/i18n/I18nContext';
interface Bottle { interface Bottle {
id: string; id: string;
purchase_price?: number | null; purchase_price?: number | null;
status: 'sealed' | 'open' | 'sampled' | 'empty';
distillery?: string; distillery?: string;
tastings?: { rating: number }[]; tastings?: { rating: number }[];
} }
@@ -19,7 +18,6 @@ interface StatsDashboardProps {
export default function StatsDashboard({ bottles }: StatsDashboardProps) { export default function StatsDashboard({ bottles }: StatsDashboardProps) {
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const stats = useMemo(() => { const stats = useMemo(() => {
const activeBottles = bottles.filter(b => b.status !== 'empty');
const totalValue = bottles.reduce((sum, b) => sum + (Number(b.purchase_price) || 0), 0); const totalValue = bottles.reduce((sum, b) => sum + (Number(b.purchase_price) || 0), 0);
const ratings = bottles.flatMap(b => b.tastings?.map(t => t.rating) || []); const ratings = bottles.flatMap(b => b.tastings?.map(t => t.rating) || []);
@@ -38,7 +36,6 @@ export default function StatsDashboard({ bottles }: StatsDashboardProps) {
return { return {
totalValue, totalValue,
activeCount: activeBottles.length,
avgRating, avgRating,
topDistillery, topDistillery,
totalCount: bottles.length totalCount: bottles.length
@@ -55,7 +52,7 @@ export default function StatsDashboard({ bottles }: StatsDashboardProps) {
}, },
{ {
label: t('home.stats.activeBottles'), label: t('home.stats.activeBottles'),
value: stats.activeCount, value: stats.totalCount,
icon: Home, icon: Home,
color: 'text-blue-600', color: 'text-blue-600',
bg: 'bg-blue-50 dark:bg-blue-900/20' bg: 'bg-blue-50 dark:bg-blue-900/20'

View File

@@ -1,72 +0,0 @@
'use client';
import React, { useState } from 'react';
import { updateBottleStatus } from '@/services/update-bottle-status';
import { Loader2, Package, Play, CheckCircle, FlaskConical } from 'lucide-react';
import { useI18n } from '@/i18n/I18nContext';
interface StatusSwitcherProps {
bottleId: string;
currentStatus: 'sealed' | 'open' | 'sampled' | 'empty';
}
export default function StatusSwitcher({ bottleId, currentStatus }: StatusSwitcherProps) {
const { t } = useI18n();
const [status, setStatus] = useState(currentStatus);
const [loading, setLoading] = useState(false);
const handleStatusChange = async (newStatus: 'sealed' | 'open' | 'sampled' | 'empty') => {
if (newStatus === status || loading) return;
setLoading(true);
try {
const result = await updateBottleStatus(bottleId, newStatus);
if (result.success) {
setStatus(newStatus);
} else {
alert(result.error || t('common.error'));
}
} catch (err) {
alert(t('common.error'));
} finally {
setLoading(false);
}
};
const options = [
{ id: 'sealed', label: t('bottle.status.sealed'), icon: Package, color: 'hover:bg-blue-500' },
{ id: 'open', label: t('bottle.status.open'), icon: Play, color: 'hover:bg-amber-500' },
{ id: 'sampled', label: t('bottle.status.sampled'), icon: FlaskConical, color: 'hover:bg-purple-500' },
{ id: 'empty', label: t('bottle.status.empty'), icon: CheckCircle, color: 'hover:bg-zinc-500' },
] as const;
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest">{t('bottle.bottleStatus')}</label>
{loading && <Loader2 className="animate-spin text-amber-600" size={14} />}
</div>
<div className="grid grid-cols-4 gap-2 p-1 bg-zinc-100 dark:bg-zinc-900/50 rounded-2xl border border-zinc-200/50 dark:border-zinc-800/50">
{options.map((opt) => {
const Icon = opt.icon;
const isActive = status === opt.id;
return (
<button
key={opt.id}
type="button"
disabled={loading}
onClick={() => handleStatusChange(opt.id)}
className={`flex flex-col items-center gap-1.5 py-3 px-1 rounded-xl text-[9px] font-black uppercase tracking-tight transition-all border-2 ${isActive
? 'bg-white dark:bg-zinc-700 border-amber-500 text-amber-600 shadow-sm ring-1 ring-black/5'
: 'border-transparent text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200'
}`}
>
<Icon size={18} className={isActive ? 'text-amber-500' : 'text-zinc-400'} />
{opt.label}
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,151 @@
'use client';
import React, { useState, useEffect, useMemo } from 'react';
import { Tag, TagCategory, getTagsByCategory, createCustomTag } from '@/services/tags';
import { X, Plus, Search, Check, Loader2, Sparkles } from 'lucide-react';
import { useI18n } from '@/i18n/I18nContext';
interface TagSelectorProps {
category: TagCategory;
selectedTagIds: string[];
onToggleTag: (tagId: string) => void;
label?: string;
}
export default function TagSelector({ category, selectedTagIds, onToggleTag, label }: TagSelectorProps) {
const { t } = useI18n();
const [tags, setTags] = useState<Tag[]>([]);
const [search, setSearch] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isCreating, setIsCreating] = useState(false);
useEffect(() => {
const fetchTags = async () => {
setIsLoading(true);
const data = await getTagsByCategory(category);
setTags(data);
setIsLoading(false);
};
fetchTags();
}, [category]);
const filteredTags = useMemo(() => {
if (!search) return tags;
const s = search.toLowerCase();
return tags.filter(tag => {
const rawMatch = tag.name.toLowerCase().includes(s);
const translatedMatch = tag.is_system_default && t(`aroma.${tag.name}`).toLowerCase().includes(s);
return rawMatch || translatedMatch;
});
}, [tags, search, t]);
const handleCreateTag = async () => {
if (!search || isCreating) return;
setIsCreating(true);
const result = await createCustomTag(search, category);
if (result.success && result.tag) {
setTags(prev => [...prev, result.tag!]);
onToggleTag(result.tag!.id);
setSearch('');
}
setIsCreating(false);
};
const selectedTags = tags.filter(t => selectedTagIds.includes(t.id));
return (
<div className="space-y-3">
{label && (
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest block">{label}</label>
)}
{/* Selected Tags */}
<div className="flex flex-wrap gap-2 min-h-[32px]">
{selectedTags.length > 0 ? (
selectedTags.map(tag => (
<button
key={tag.id}
type="button"
onClick={() => onToggleTag(tag.id)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-amber-600 text-white rounded-full text-[10px] font-black uppercase tracking-tight shadow-sm shadow-amber-600/20 animate-in fade-in zoom-in-95"
>
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
<X size={12} />
</button>
))
) : (
<span className="text-[10px] italic text-zinc-400 font-medium">Noch keine Tags gewählt...</span>
)}
</div>
{/* Search and Suggest */}
<div className="relative">
<div className="relative flex items-center">
<Search className="absolute left-3 text-zinc-400" size={14} />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Tag suchen oder hinzufügen..."
className="w-full pl-9 pr-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-xs focus:ring-2 focus:ring-amber-500 outline-none transition-all dark:text-zinc-200 placeholder:text-zinc-400"
/>
{isCreating && (
<Loader2 className="absolute right-3 animate-spin text-amber-600" size={14} />
)}
</div>
{search && (
<div className="absolute z-10 w-full mt-2 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl overflow-hidden animate-in fade-in slide-in-from-top-2">
<div className="max-h-48 overflow-y-auto">
{filteredTags.length > 0 ? (
filteredTags.map(tag => (
<button
key={tag.id}
type="button"
onClick={() => {
onToggleTag(tag.id);
setSearch('');
}}
className="w-full px-4 py-2.5 text-left text-xs font-bold text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700/50 flex items-center justify-between border-b border-zinc-100 dark:border-zinc-700 last:border-0"
>
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
{selectedTagIds.includes(tag.id) && <Check size={12} className="text-amber-600" />}
</button>
))
) : (
<button
type="button"
onClick={handleCreateTag}
className="w-full px-4 py-3 text-left text-xs font-bold text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/10 flex items-center gap-2"
>
<Plus size={14} />
"{search}" als neuen Tag hinzufügen
</button>
)}
</div>
</div>
)}
</div>
{/* Suggestions Chips (limit to 6 random or most common) */}
{!search && tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{tags
.filter(t => !selectedTagIds.includes(t.id))
.slice(0, 8)
.map(tag => (
<button
key={tag.id}
type="button"
onClick={() => onToggleTag(tag.id)}
className="px-2.5 py-1 rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400 text-[10px] font-bold uppercase tracking-tight hover:bg-zinc-200 dark:hover:bg-zinc-700 hover:text-zinc-700 dark:hover:text-zinc-200 transition-colors border border-zinc-200/50 dark:border-zinc-700/50"
>
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -16,7 +16,7 @@ interface Tasting {
is_sample?: boolean; is_sample?: boolean;
bottle_id: string; bottle_id: string;
created_at: string; created_at: string;
tasting_tags?: { tasting_buddies?: {
buddies: { buddies: {
id: string; id: string;
name: string; name: string;
@@ -26,6 +26,14 @@ interface Tasting {
id: string; id: string;
name: string; name: string;
}; };
tasting_tags?: {
tags: {
id: string;
name: string;
category: string;
is_system_default: boolean;
}
}[];
user_id: string; user_id: string;
} }
@@ -188,13 +196,30 @@ export default function TastingList({ initialTastings, currentUserId }: TastingL
</div> </div>
{note.tasting_tags && note.tasting_tags.length > 0 && ( {note.tasting_tags && note.tasting_tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-2">
{note.tasting_tags.map(tt => (
<span
key={tt.tags.id}
className={`px-2 py-0.5 rounded-lg text-[10px] font-bold uppercase tracking-tight border ${tt.tags.category === 'nose' ? 'bg-green-50 text-green-700 border-green-100 dark:bg-green-900/20 dark:text-green-400 dark:border-green-800/50' :
tt.tags.category === 'taste' ? 'bg-blue-50 text-blue-700 border-blue-100 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800/50' :
tt.tags.category === 'finish' ? 'bg-amber-50 text-amber-700 border-amber-100 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-800/50' :
'bg-zinc-50 text-zinc-700 border-zinc-100 dark:bg-zinc-900/20 dark:text-zinc-400 dark:border-zinc-800/50'
}`}
>
{tt.tags.is_system_default ? t(`aroma.${tt.tags.name}`) : tt.tags.name}
</span>
))}
</div>
)}
{note.tasting_buddies && note.tasting_buddies.length > 0 && (
<div className="pt-3 flex items-center justify-between border-t border-zinc-100 dark:border-zinc-800"> <div className="pt-3 flex items-center justify-between border-t border-zinc-100 dark:border-zinc-800">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-[10px] font-black text-zinc-400 uppercase tracking-widest flex items-center gap-1.5 mr-1"> <span className="text-[10px] font-black text-zinc-400 uppercase tracking-widest flex items-center gap-1.5 mr-1">
<Users size={12} className="text-amber-500" /> <Users size={12} className="text-amber-500" />
{t('tasting.with') || 'Mit'}: {t('tasting.with') || 'Mit'}:
</span> </span>
<AvatarStack names={note.tasting_tags.map(tag => tag.buddies.name)} /> <AvatarStack names={note.tasting_buddies.map(tag => tag.buddies.name)} />
</div> </div>
</div> </div>
)} )}

View File

@@ -2,10 +2,11 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { saveTasting } from '@/services/save-tasting'; import { saveTasting } from '@/services/save-tasting';
import { Loader2, Send, Star, Users, Check, Sparkles, Droplets } from 'lucide-react'; import { Loader2, Send, Star, Users, Check, Sparkles, Droplets, Wind, Utensils, Zap } from 'lucide-react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'; import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { useI18n } from '@/i18n/I18nContext'; import { useI18n } from '@/i18n/I18nContext';
import { useSession } from '@/context/SessionContext'; import { useSession } from '@/context/SessionContext';
import TagSelector from './TagSelector';
interface Buddy { interface Buddy {
id: string; id: string;
@@ -29,6 +30,9 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [buddies, setBuddies] = useState<Buddy[]>([]); const [buddies, setBuddies] = useState<Buddy[]>([]);
const [selectedBuddyIds, setSelectedBuddyIds] = useState<string[]>([]); const [selectedBuddyIds, setSelectedBuddyIds] = useState<string[]>([]);
const [noseTagIds, setNoseTagIds] = useState<string[]>([]);
const [palateTagIds, setPalateTagIds] = useState<string[]>([]);
const [finishTagIds, setFinishTagIds] = useState<string[]>([]);
const { activeSession } = useSession(); const { activeSession } = useSession();
const effectiveSessionId = sessionId || activeSession?.id; const effectiveSessionId = sessionId || activeSession?.id;
@@ -62,6 +66,18 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
); );
}; };
const toggleNoseTag = (id: string) => {
setNoseTagIds(prev => prev.includes(id) ? prev.filter(tid => tid !== id) : [...prev, id]);
};
const togglePalateTag = (id: string) => {
setPalateTagIds(prev => prev.includes(id) ? prev.filter(tid => tid !== id) : [...prev, id]);
};
const toggleFinishTag = (id: string) => {
setFinishTagIds(prev => prev.includes(id) ? prev.filter(tid => tid !== id) : [...prev, id]);
};
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
@@ -77,6 +93,7 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
finish_notes: finish, finish_notes: finish,
is_sample: isSample, is_sample: isSample,
buddy_ids: selectedBuddyIds, buddy_ids: selectedBuddyIds,
tag_ids: [...noseTagIds, ...palateTagIds, ...finishTagIds],
}); });
if (result.success) { if (result.success) {
@@ -84,6 +101,9 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
setPalate(''); setPalate('');
setFinish(''); setFinish('');
setSelectedBuddyIds([]); setSelectedBuddyIds([]);
setNoseTagIds([]);
setPalateTagIds([]);
setFinishTagIds([]);
// We don't need to manually refresh because of revalidatePath in the server action // We don't need to manually refresh because of revalidatePath in the server action
} else { } else {
setError(result.error || t('common.error')); setError(result.error || t('common.error'));
@@ -158,38 +178,72 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
</div> </div>
</div> </div>
<div className="space-y-4">
<div className="bg-zinc-50 dark:bg-zinc-800/50 p-4 rounded-2xl border border-zinc-200 dark:border-zinc-700/50 space-y-4">
<TagSelector
category="nose"
selectedTagIds={noseTagIds}
onToggleTag={toggleNoseTag}
label={t('tasting.nose')}
/>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-bold text-zinc-400 uppercase tracking-tighter">{t('tasting.nose')}</label> <label className="text-[10px] font-black text-zinc-400 uppercase tracking-widest block opacity-50">Zusätzliche Notizen</label>
<textarea <textarea
value={nose} value={nose}
onChange={(e) => setNose(e.target.value)} onChange={(e) => setNose(e.target.value)}
placeholder={t('tasting.notesPlaceholder')} placeholder={t('tasting.notesPlaceholder')}
rows={2} rows={2}
className="w-full p-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200" className="w-full p-3 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200"
/> />
</div> </div>
</div>
<div className="bg-zinc-50 dark:bg-zinc-800/50 p-4 rounded-2xl border border-zinc-200 dark:border-zinc-700/50 space-y-4">
<TagSelector
category="taste"
selectedTagIds={palateTagIds}
onToggleTag={togglePalateTag}
label={t('tasting.palate')}
/>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-bold text-zinc-400 uppercase tracking-tighter">{t('tasting.palate')}</label> <label className="text-[10px] font-black text-zinc-400 uppercase tracking-widest block opacity-50">Zusätzliche Notizen</label>
<textarea <textarea
value={palate} value={palate}
onChange={(e) => setPalate(e.target.value)} onChange={(e) => setPalate(e.target.value)}
placeholder={t('tasting.notesPlaceholder')} placeholder={t('tasting.notesPlaceholder')}
rows={2} rows={2}
className="w-full p-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200" className="w-full p-3 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200"
/> />
</div> </div>
</div>
<div className="bg-zinc-50 dark:bg-zinc-800/50 p-4 rounded-2xl border border-zinc-200 dark:border-zinc-700/50 space-y-6">
<TagSelector
category="finish"
selectedTagIds={finishTagIds}
onToggleTag={toggleFinishTag}
label={t('tasting.finish')}
/>
<TagSelector
category="texture"
selectedTagIds={finishTagIds} // Using finish state for texture for now, or separate if needed
onToggleTag={toggleFinishTag}
label="Textur & Mundgefühl"
/>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-bold text-zinc-400 uppercase tracking-tighter">{t('tasting.finish')}</label> <label className="text-[10px] font-black text-zinc-400 uppercase tracking-widest block opacity-50">Zusätzliche Notizen</label>
<textarea <textarea
value={finish} value={finish}
onChange={(e) => setFinish(e.target.value)} onChange={(e) => setFinish(e.target.value)}
placeholder={t('tasting.notesPlaceholder')} placeholder={t('tasting.notesPlaceholder')}
rows={2} rows={2}
className="w-full p-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200" className="w-full p-3 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200"
/> />
</div> </div>
</div>
</div>
{buddies.length > 0 && ( {buddies.length > 0 && (
<div className="space-y-3"> <div className="space-y-3">

View File

@@ -165,4 +165,91 @@ export const de: TranslationKeys = {
noSessions: 'Noch keine Sessions vorhanden.', noSessions: 'Noch keine Sessions vorhanden.',
expiryWarning: 'Diese Session läuft bald ab.', expiryWarning: 'Diese Session läuft bald ab.',
}, },
aroma: {
'Apfel': 'Apfel',
'Grüner Apfel': 'Grüner Apfel',
'Bratapfel': 'Bratapfel',
'Birne': 'Birne',
'Zitrus': 'Zitrus',
'Zitrone': 'Zitrone',
'Orange': 'Orange',
'Orangenschale': 'Orangenschale',
'Pfirsich': 'Pfirsich',
'Aprikose': 'Aprikose',
'Banane': 'Banane',
'Ananas': 'Ananas',
'Tropische Früchte': 'Tropische Früchte',
'Kirsche': 'Kirsche',
'Beeren': 'Beeren',
'Brombeere': 'Brombeere',
'Himbeere': 'Himbeere',
'Pflaume': 'Pflaume',
'Trockenfrüchte': 'Trockenfrüchte',
'Rosinen': 'Rosinen',
'Datteln': 'Datteln',
'Feigen': 'Feigen',
'Vanille': 'Vanille',
'Honig': 'Honig',
'Karamell': 'Karamell',
'Toffee': 'Toffee',
'Schokolade': 'Schokolade',
'Zartbitterschokolade': 'Zartbitterschokolade',
'Milchschokolade': 'Milchschokolade',
'Malz': 'Malz',
'Müsli': 'Müsli',
'Butter': 'Butter',
'Butterkeks': 'Butterkeks',
'Marzipan': 'Marzipan',
'Mandel': 'Mandel',
'Sahnebonbon': 'Sahnebonbon',
'Eiche': 'Eiche',
'Zimt': 'Zimt',
'Pfeffer': 'Pfeffer',
'Muskatnuss': 'Muskatnuss',
'Ingwer': 'Ingwer',
'Nelke': 'Nelke',
'Walnuss': 'Walnuss',
'Haselnuss': 'Haselnuss',
'Geröstete Nüsse': 'Geröstete Nüsse',
'Lagerfeuer': 'Lagerfeuer',
'Holzkohle': 'Holzkohle',
'Torfrauch': 'Torfrauch',
'Asche': 'Asche',
'Jod': 'Jod',
'Medizinisch': 'Medizinisch',
'Teer': 'Teer',
'Asphalt': 'Asphalt',
'Geräucherter Schinken': 'Geräucherter Schinken',
'Speck': 'Speck',
'Grillfleisch': 'Grillfleisch',
'Meersalz': 'Meersalz',
'Salzlake': 'Salzlake',
'Seetang': 'Seetang',
'Algen': 'Algen',
'Austern': 'Austern',
'Meeresbrise': 'Meeresbrise',
'Heidekraut': 'Heidekraut',
'Gras': 'Gras',
'Heu': 'Heu',
'Minze': 'Minze',
'Menthol': 'Menthol',
'Eukalyptus': 'Eukalyptus',
'Tabak': 'Tabak',
'Leder': 'Leder',
'Tee': 'Tee',
'Kurz & Knackig': 'Kurz & Knackig',
'Mittellang': 'Mittellang',
'Lang anhaltend': 'Lang anhaltend',
'Ewig': 'Ewig',
'Ölig': 'Ölig',
'Viskos': 'Viskos',
'Trocken': 'Trocken',
'Adstringierend': 'Adstringierend',
'Wärmend': 'Wärmend',
'Scharf': 'Scharf',
'Beißend': 'Beißend',
'Weich': 'Weich',
'Samtig': 'Samtig',
'Wässrig': 'Wässrig',
}
}; };

View File

@@ -165,4 +165,91 @@ export const en: TranslationKeys = {
noSessions: 'No sessions yet.', noSessions: 'No sessions yet.',
expiryWarning: 'This session will expire soon.', expiryWarning: 'This session will expire soon.',
}, },
aroma: {
'Apfel': 'Apple',
'Grüner Apfel': 'Green Apple',
'Bratapfel': 'Baked Apple',
'Birne': 'Pear',
'Zitrus': 'Citrus',
'Zitrone': 'Lemon',
'Orange': 'Orange',
'Orangenschale': 'Orange Peel',
'Pfirsich': 'Peach',
'Aprikose': 'Apricot',
'Banane': 'Banana',
'Ananas': 'Pineapple',
'Tropische Früchte': 'Tropical Fruits',
'Kirsche': 'Cherry',
'Beeren': 'Berries',
'Brombeere': 'Blackberry',
'Himbeere': 'Raspberry',
'Pflaume': 'Plum',
'Trockenfrüchte': 'Dried Fruits',
'Rosinen': 'Raisins',
'Datteln': 'Dates',
'Feigen': 'Figs',
'Vanille': 'Vanilla',
'Honig': 'Honey',
'Karamell': 'Caramel',
'Toffee': 'Toffee',
'Schokolade': 'Chocolate',
'Zartbitterschokolade': 'Dark Chocolate',
'Milchschokolade': 'Milk Chocolate',
'Malz': 'Malt',
'Müsli': 'Cereal',
'Butter': 'Butter',
'Butterkeks': 'Butter Cookie',
'Marzipan': 'Marzipan',
'Mandel': 'Almond',
'Sahnebonbon': 'Butterscotch',
'Eiche': 'Oak',
'Zimt': 'Cinnamon',
'Pfeffer': 'Pepper',
'Muskatnuss': 'Nutmeg',
'Ingwer': 'Ginger',
'Nelke': 'Clove',
'Walnuss': 'Walnut',
'Haselnuss': 'Hazelnut',
'Geröstete Nüsse': 'Roasted Nuts',
'Lagerfeuer': 'Campfire',
'Holzkohle': 'Charcoal',
'Torfrauch': 'Peat Smoke',
'Asche': 'Ash',
'Jod': 'Iodine',
'Medizinisch': 'Medicinal',
'Teer': 'Tar',
'Asphalt': 'Asphalt',
'Geräucherter Schinken': 'Smoked Ham',
'Speck': 'Bacon',
'Grillfleisch': 'Grilled Meat',
'Meersalz': 'Sea Salt',
'Salzlake': 'Brine',
'Seetang': 'Seaweed',
'Algen': 'Algae',
'Austern': 'Oysters',
'Meeresbrise': 'Sea Breeze',
'Heidekraut': 'Heather',
'Gras': 'Grass',
'Heu': 'Hay',
'Minze': 'Mint',
'Menthol': 'Menthol',
'Eukalyptus': 'Eucalyptus',
'Tabak': 'Tobacco',
'Leder': 'Leather',
'Tee': 'Tea',
'Kurz & Knackig': 'Short & Sharp',
'Mittellang': 'Medium',
'Lang anhaltend': 'Long Finish',
'Ewig': 'Eternal',
'Ölig': 'Oily',
'Viskos': 'Viscous',
'Trocken': 'Dry',
'Adstringierend': 'Astringent',
'Wärmend': 'Warming',
'Scharf': 'Sharp',
'Beißend': 'Biting',
'Weich': 'Soft',
'Samtig': 'Velvety',
'Wässrig': 'Watery',
}
}; };

View File

@@ -163,4 +163,5 @@ export type TranslationKeys = {
noSessions: string; noSessions: string;
expiryWarning: string; expiryWarning: string;
}; };
aroma: Record<string, string>;
}; };

6
src/lib/ai-client.ts Normal file
View File

@@ -0,0 +1,6 @@
import { OpenAI } from 'openai';
export const aiClient = new OpenAI({
baseURL: 'https://api.tokenfactory.nebius.com/v1/',
apiKey: process.env.NEBIUS_API_KEY,
});

19
src/lib/supabase-admin.ts Normal file
View File

@@ -0,0 +1,19 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !supabaseServiceKey) {
if (process.env.NODE_ENV !== 'production') {
console.warn('Supabase Admin Error: SUPABASE_SERVICE_ROLE_KEY is missing.');
}
}
export const supabaseAdmin = (supabaseUrl && supabaseServiceKey)
? createClient(supabaseUrl, supabaseServiceKey, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
})
: null as any;

View File

@@ -0,0 +1,124 @@
'use server';
import { aiClient } from '@/lib/ai-client';
import { SYSTEM_INSTRUCTION as GEMINI_SYSTEM_INSTRUCTION } from '@/lib/gemini';
import { BottleMetadataSchema, AnalysisResponse, BottleMetadata } from '@/types/whisky';
import { createServerActionClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
import { createHash } from 'crypto';
import { trackApiUsage } from './track-api-usage';
import { checkCreditBalance, deductCredits } from './credit-service';
export async function analyzeBottleNebius(base64Image: string): Promise<AnalysisResponse & { search_string?: string }> {
const supabase = createServerActionClient({ cookies });
if (!process.env.NEBIUS_API_KEY) {
return { success: false, error: 'NEBIUS_API_KEY is not configured.' };
}
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session || !session.user) {
return { success: false, error: 'Nicht autorisiert oder Session abgelaufen.' };
}
const userId = session.user.id;
// Check credit balance (using same gemini_ai type for now or create new one)
const creditCheck = await checkCreditBalance(userId, 'gemini_ai');
if (!creditCheck.allowed) {
return {
success: false,
error: `Nicht genügend Credits. Du benötigst ${creditCheck.cost} Credits, hast aber nur ${creditCheck.balance}.`
};
}
const base64Data = base64Image.split(',')[1] || base64Image;
const imageHash = createHash('sha256').update(base64Data).digest('hex');
// Check Cache (Optional: skip if you want fresh AI results for testing)
const { data: cachedResult } = await supabase
.from('vision_cache')
.select('result')
.eq('hash', imageHash)
.maybeSingle();
if (cachedResult) {
console.log(`[Nebius Cache] Hit! hash: ${imageHash}`);
return {
success: true,
data: cachedResult.result as any,
};
}
console.log(`[Nebius AI] Calling Qwen2.5-VL...`);
const response = await aiClient.chat.completions.create({
model: "Qwen/Qwen2.5-VL-72B-Instruct",
messages: [
{
role: "system",
content: GEMINI_SYSTEM_INSTRUCTION + "\nAdditionally, generate a 'search_string' field for Whiskybase in this format: 'site:whiskybase.com [Distillery] [Name] [Vintage]'. Include this field in the JSON object."
},
{
role: "user",
content: [
{
type: "text",
text: "Extract whisky metadata from this image."
},
{
type: "image_url",
image_url: {
url: `data:image/jpeg;base64,${base64Data}`
}
}
]
}
],
response_format: { type: "json_object" }
});
const content = response.choices[0].message.content;
if (!content) throw new Error('Empty response from Nebius AI');
// Extract JSON content in case the model wraps it in markdown blocks
const jsonContent = content.match(/\{[\s\S]*\}/)?.[0] || content;
const jsonData = JSON.parse(jsonContent);
// Extract search_string before validation if it's not in schema
const searchString = jsonData.search_string;
delete jsonData.search_string;
const validatedData = BottleMetadataSchema.parse(jsonData);
// Track usage
await trackApiUsage({
userId: userId,
apiType: 'gemini_ai', // Keep tracking as gemini_ai for budget or separate later
endpoint: 'nebius/qwen2.5-vl',
success: true
});
// Deduct credits
await deductCredits(userId, 'gemini_ai', 'Nebius AI analysis');
// Store in Cache
await supabase
.from('vision_cache')
.insert({ hash: imageHash, result: validatedData });
return {
success: true,
data: validatedData,
search_string: searchString
};
} catch (error) {
console.error('Nebius Analysis Error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Nebius AI analysis failed.',
};
}
}

View File

@@ -0,0 +1,60 @@
'use server';
/**
* Service to search Brave for a Whiskybase link and extract the ID.
*/
export async function searchBraveForWhiskybase(query: string) {
const apiKey = process.env.BRAVE_API_KEY;
if (!apiKey) {
console.error('BRAVE_API_KEY is not configured.');
return { success: false, error: 'Brave Search API Key missing.' };
}
try {
const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query + ' site:whiskybase.com')}`;
const response = await fetch(url, {
headers: {
'X-Subscription-Token': apiKey,
'Accept': 'application/json',
}
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `Brave API error: ${response.status}`);
}
const data = await response.json();
if (!data.web || !data.web.results || data.web.results.length === 0) {
return { success: false, error: 'No results found on Brave.' };
}
// Try to find a Whiskybase ID in the results
const wbRegex = /whiskybase\.com\/whiskies\/whisky\/(\d+)\//;
for (const result of data.web.results) {
const url = result.url;
const match = url.match(wbRegex);
if (match && match[1]) {
return {
success: true,
id: match[1],
url: url,
title: result.title
};
}
}
return { success: false, error: 'No valid Whiskybase ID found in results.' };
} catch (error) {
console.error('Brave Search Error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during Brave search.'
};
}
}

View File

@@ -0,0 +1,87 @@
'use server';
import { analyzeBottle } from './analyze-bottle';
import { analyzeBottleNebius } from './analyze-bottle-nebius';
import { searchBraveForWhiskybase } from './brave-search';
import { supabase } from '@/lib/supabase';
import { supabaseAdmin } from '@/lib/supabase-admin';
import { AnalysisResponse, BottleMetadata } from '@/types/whisky';
export async function magicScan(base64Image: string, provider: 'gemini' | 'nebius' = 'gemini'): Promise<AnalysisResponse & { wb_id?: string }> {
try {
// 1. AI Analysis
let aiResponse: any;
if (provider === 'nebius') {
aiResponse = await analyzeBottleNebius(base64Image);
} else {
aiResponse = await analyzeBottle(base64Image);
}
if (!aiResponse.success || !aiResponse.data) {
return aiResponse;
}
const data: BottleMetadata = aiResponse.data;
const searchString = aiResponse.search_string || `${data.distillery || ''} ${data.name || ''} ${data.vintage || data.age || ''}`.trim();
if (!searchString) {
return { ...aiResponse, wb_id: undefined };
}
// 2. DB Cache Check (global_products)
// We use the regular supabase client for reading
const { data: cacheHit } = await supabase
.from('global_products')
.select('wb_id')
.textSearch('search_vector', `'${searchString}'`, { config: 'simple' })
.limit(1)
.maybeSingle();
if (cacheHit) {
console.log(`[Magic Scan] Cache Hit for ${searchString}: ${cacheHit.wb_id}`);
return {
...aiResponse,
wb_id: cacheHit.wb_id
};
}
// 3. Fallback to Brave Search
console.log(`[Magic Scan] Cache Miss for ${searchString}. Calling Brave...`);
const braveResult = await searchBraveForWhiskybase(searchString);
if (braveResult.success && braveResult.id) {
console.log(`[Magic Scan] Brave found ID: ${braveResult.id}`);
// 4. Cache Write (using Admin client)
if (supabaseAdmin) {
const { error: saveError } = await supabaseAdmin
.from('global_products')
.insert({
wb_id: braveResult.id,
full_name: searchString, // We save the search string as the name for future hits
});
if (saveError) {
console.warn(`[Magic Scan] Failed to save to global_products: ${saveError.message}`);
}
}
return {
...aiResponse,
wb_id: braveResult.id
};
}
return {
...aiResponse,
wb_id: undefined
};
} catch (error) {
console.error('Magic Scan Error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Magic Scan failed.'
};
}
}

View File

@@ -14,6 +14,7 @@ export async function saveTasting(data: {
finish_notes?: string; finish_notes?: string;
is_sample?: boolean; is_sample?: boolean;
buddy_ids?: string[]; buddy_ids?: string[];
tag_ids?: string[];
}) { }) {
const supabase = createServerActionClient({ cookies }); const supabase = createServerActionClient({ cookies });
@@ -48,22 +49,38 @@ export async function saveTasting(data: {
// Add buddy tags if any // Add buddy tags if any
if (data.buddy_ids && data.buddy_ids.length > 0) { if (data.buddy_ids && data.buddy_ids.length > 0) {
const tags = data.buddy_ids.map(buddyId => ({ const buddies = data.buddy_ids.map(buddyId => ({
tasting_id: tasting.id, tasting_id: tasting.id,
buddy_id: buddyId, buddy_id: buddyId,
user_id: session.user.id user_id: session.user.id
})); }));
const { error: tagError } = await supabase const { error: tagError } = await supabase
.from('tasting_tags') .from('tasting_buddies')
.insert(tags); .insert(buddies);
if (tagError) { if (tagError) {
console.error('Error adding tasting tags:', tagError); console.error('Error adding tasting buddies:', tagError);
// We don't throw here to not fail the whole tasting save, // We don't throw here to not fail the whole tasting save,
// but in a real app we might want more robust error handling // but in a real app we might want more robust error handling
} }
} }
// Add aroma tags if any
if (data.tag_ids && data.tag_ids.length > 0) {
const aromaTags = data.tag_ids.map(tagId => ({
tasting_id: tasting.id,
tag_id: tagId,
user_id: session.user.id
}));
const { error: aromaTagError } = await supabase
.from('tasting_tags')
.insert(aromaTags);
if (aromaTagError) {
console.error('Error adding aroma tags:', aromaTagError);
}
}
revalidatePath(`/bottles/${data.bottle_id}`); revalidatePath(`/bottles/${data.bottle_id}`);
return { success: true, data: tasting }; return { success: true, data: tasting };

79
src/services/tags.ts Normal file
View File

@@ -0,0 +1,79 @@
'use server';
import { createServerActionClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
export type TagCategory = 'nose' | 'taste' | 'finish' | 'texture';
export interface Tag {
id: string;
name: string;
category: TagCategory;
is_system_default: boolean;
popularity_score: number;
created_by?: string;
}
/**
* Fetch tags by category
*/
export async function getTagsByCategory(category: TagCategory): Promise<Tag[]> {
const supabase = createServerActionClient({ cookies });
const { data, error } = await supabase
.from('tags')
.select('*')
.eq('category', category)
.order('popularity_score', { ascending: false })
.order('name');
if (error) {
console.error(`Error fetching tags for ${category}:`, error);
return [];
}
return data || [];
}
/**
* Create a custom user tag
*/
export async function createCustomTag(name: string, category: TagCategory): Promise<{ success: boolean; tag?: Tag; error?: string }> {
const supabase = createServerActionClient({ cookies });
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session) throw new Error('Nicht autorisiert');
const { data, error } = await supabase
.from('tags')
.insert({
name,
category,
is_system_default: false,
created_by: session.user.id
})
.select()
.single();
if (error) {
if (error.code === '23505') { // Unique constraint violation
// Try to fetch the existing tag
const { data: existingTag } = await supabase
.from('tags')
.select('*')
.eq('name', name)
.eq('category', category)
.single();
return { success: true, tag: existingTag || undefined };
}
throw error;
}
return { success: true, tag: data };
} catch (error) {
console.error('Error creating custom tag:', error);
return { success: false, error: 'Tag konnte nicht erstellt werden' };
}
}

View File

@@ -1,17 +1,17 @@
import { z } from 'zod'; import { z } from 'zod';
export const BottleMetadataSchema = z.object({ export const BottleMetadataSchema = z.object({
name: z.string().nullable(), name: z.string().nullish(),
distillery: z.string().nullable(), distillery: z.string().nullish(),
category: z.string().nullable(), category: z.string().nullish(),
abv: z.number().nullable(), abv: z.number().nullish(),
age: z.number().nullable(), age: z.number().nullish(),
vintage: z.string().nullable(), vintage: z.string().nullish(),
bottleCode: z.string().nullable(), bottleCode: z.string().nullish(),
whiskybaseId: z.string().nullable(), whiskybaseId: z.string().nullish(),
distilled_at: z.string().nullable(), distilled_at: z.string().nullish(),
bottled_at: z.string().nullable(), bottled_at: z.string().nullish(),
batch_info: z.string().nullable(), batch_info: z.string().nullish(),
is_whisky: z.boolean().default(true), is_whisky: z.boolean().default(true),
confidence: z.number().min(0).max(100).default(100), confidence: z.number().min(0).max(100).default(100),
}); });

View File

@@ -0,0 +1,161 @@
-- Migration for Advanced Tagging System
-- 1. Rename existing 'tasting_tags' to 'tasting_buddies'
-- This table currently stores which buddies are part of a tasting.
DO $$
BEGIN
IF EXISTS (SELECT FROM pg_tables WHERE schemaname = 'public' AND tablename = 'tasting_tags') THEN
ALTER TABLE tasting_tags RENAME TO tasting_buddies;
END IF;
END $$;
-- 2. Create 'tags' table
CREATE TYPE tag_category AS ENUM ('nose', 'taste', 'finish', 'texture');
CREATE TABLE IF NOT EXISTS tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
category tag_category NOT NULL,
is_system_default BOOLEAN DEFAULT false,
popularity_score INTEGER DEFAULT 3, -- 1 to 5
created_by UUID REFERENCES profiles(id) ON DELETE CASCADE,
UNIQUE(name, category)
);
-- 3. Create new 'tasting_tags' junction table (for aroma tags)
CREATE TABLE IF NOT EXISTS tasting_tags (
tasting_id UUID REFERENCES tastings(id) ON DELETE CASCADE NOT NULL,
tag_id UUID REFERENCES tags(id) ON DELETE CASCADE NOT NULL,
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL,
PRIMARY KEY (tasting_id, tag_id)
);
-- 4. Enable RLS
ALTER TABLE tags ENABLE ROW LEVEL SECURITY;
ALTER TABLE tasting_tags ENABLE ROW LEVEL SECURITY;
-- 5. Policies for 'tags'
-- Everyone can read system default tags and their own custom tags
DROP POLICY IF EXISTS "tags_select_policy" ON tags;
CREATE POLICY "tags_select_policy" ON tags
FOR SELECT USING (
is_system_default = true OR
(SELECT auth.uid()) = created_by OR
EXISTS (SELECT 1 FROM admin_users WHERE user_id = (SELECT auth.uid()))
);
-- Users can insert their own custom tags
DROP POLICY IF EXISTS "tags_insert_policy" ON tags;
CREATE POLICY "tags_insert_policy" ON tags
FOR INSERT WITH CHECK (
(SELECT auth.uid()) = created_by
);
-- 6. Policies for 'tasting_tags' (junction)
DROP POLICY IF EXISTS "tasting_tags_owner_policy" ON tasting_tags;
CREATE POLICY "tasting_tags_owner_policy" ON tasting_tags
FOR ALL USING ((SELECT auth.uid()) = user_id);
-- 7. Insert Master List of System Tags
INSERT INTO tags (name, category, is_system_default) VALUES
-- FRUCHTIG (FRUITY)
('Apfel', 'nose', true), ('Apfel', 'taste', true),
('Grüner Apfel', 'nose', true), ('Grüner Apfel', 'taste', true),
('Bratapfel', 'nose', true), ('Bratapfel', 'taste', true),
('Birne', 'nose', true), ('Birne', 'taste', true),
('Zitrone', 'nose', true), ('Zitrone', 'taste', true),
('Zitrus', 'nose', true), ('Zitrus', 'taste', true),
('Orange', 'nose', true), ('Orange', 'taste', true),
('Orangenschale', 'nose', true), ('Orangenschale', 'taste', true),
('Pfirsich', 'nose', true), ('Pfirsich', 'taste', true),
('Aprikose', 'nose', true), ('Aprikose', 'taste', true),
('Banane', 'nose', true), ('Banane', 'taste', true),
('Ananas', 'nose', true), ('Ananas', 'taste', true),
('Tropische Früchte', 'nose', true), ('Tropische Früchte', 'taste', true),
('Kirsche', 'nose', true), ('Kirsche', 'taste', true),
('Beeren', 'nose', true), ('Beeren', 'taste', true),
('Brombeere', 'nose', true), ('Brombeere', 'taste', true),
('Himbeere', 'nose', true), ('Himbeere', 'taste', true),
('Pflaume', 'nose', true), ('Pflaume', 'taste', true),
('Trockenfrüchte', 'nose', true), ('Trockenfrüchte', 'taste', true),
('Rosinen', 'nose', true), ('Rosinen', 'taste', true),
('Datteln', 'nose', true), ('Datteln', 'taste', true),
('Feigen', 'nose', true), ('Feigen', 'taste', true),
-- SÜSS & CREMIG (SWEET & CREAMY)
('Vanille', 'nose', true), ('Vanille', 'taste', true),
('Honig', 'nose', true), ('Honig', 'taste', true),
('Karamell', 'nose', true), ('Karamell', 'taste', true),
('Toffee', 'nose', true), ('Toffee', 'taste', true),
('Schokolade', 'nose', true), ('Schokolade', 'taste', true),
('Zartbitterschokolade', 'nose', true), ('Zartbitterschokolade', 'taste', true),
('Milchschokolade', 'nose', true), ('Milchschokolade', 'taste', true),
('Malz', 'nose', true), ('Malz', 'taste', true),
('Müsli', 'nose', true), ('Müsli', 'taste', true),
('Butter', 'nose', true), ('Butter', 'taste', true),
('Butterkeks', 'nose', true), ('Butterkeks', 'taste', true),
('Marzipan', 'nose', true), ('Marzipan', 'taste', true),
('Mandel', 'nose', true), ('Mandel', 'taste', true),
('Sahnebonbon', 'nose', true), ('Sahnebonbon', 'taste', true),
-- WÜRZIG & NUSSIG (SPICY & NUTTY)
('Eiche', 'nose', true), ('Eiche', 'taste', true),
('Zimt', 'nose', true), ('Zimt', 'taste', true),
('Pfeffer', 'nose', true), ('Pfeffer', 'taste', true),
('Muskatnuss', 'nose', true), ('Muskatnuss', 'taste', true),
('Ingwer', 'nose', true), ('Ingwer', 'taste', true),
('Nelke', 'nose', true), ('Nelke', 'taste', true),
('Walnuss', 'nose', true), ('Walnuss', 'taste', true),
('Haselnuss', 'nose', true), ('Haselnuss', 'taste', true),
('Geröstete Nüsse', 'nose', true), ('Geröstete Nüsse', 'taste', true),
-- RAUCHIG & TORFIG (PEATY & SMOKY)
('Lagerfeuer', 'nose', true), ('Lagerfeuer', 'taste', true),
('Holzkohle', 'nose', true), ('Holzkohle', 'taste', true),
('Torfrauch', 'nose', true), ('Torfrauch', 'taste', true),
('Asche', 'nose', true), ('Asche', 'taste', true),
('Jod', 'nose', true), ('Jod', 'taste', true),
('Medizinisch', 'nose', true), ('Medizinisch', 'taste', true),
('Teer', 'nose', true), ('Teer', 'taste', true),
('Asphalt', 'nose', true), ('Asphalt', 'taste', true),
('Geräucherter Schinken', 'nose', true), ('Geräucherter Schinken', 'taste', true),
('Speck', 'nose', true), ('Speck', 'taste', true),
('Grillfleisch', 'nose', true), ('Grillfleisch', 'taste', true),
-- MARITIM & SALZIG (COASTAL)
('Meersalz', 'nose', true), ('Meersalz', 'taste', true),
('Salzlake', 'nose', true), ('Salzlake', 'taste', true),
('Seetang', 'nose', true), ('Seetang', 'taste', true),
('Algen', 'nose', true), ('Algen', 'taste', true),
('Austern', 'nose', true), ('Austern', 'taste', true),
('Meeresbrise', 'nose', true), ('Meeresbrise', 'taste', true),
-- FLORAL & KRÄUTER (FLORAL & HERBAL)
('Heidekraut', 'nose', true), ('Heidekraut', 'taste', true),
('Gras', 'nose', true), ('Gras', 'taste', true),
('Heu', 'nose', true), ('Heu', 'taste', true),
('Minze', 'nose', true), ('Minze', 'taste', true),
('Menthol', 'nose', true), ('Menthol', 'taste', true),
('Eukalyptus', 'nose', true), ('Eukalyptus', 'taste', true),
('Tabak', 'nose', true), ('Tabak', 'taste', true),
('Leder', 'nose', true), ('Leder', 'taste', true),
('Tee', 'nose', true), ('Tee', 'taste', true),
-- FINISH DAUER
('Kurz & Knackig', 'finish', true),
('Mittellang', 'finish', true),
('Lang anhaltend', 'finish', true),
('Ewig', 'finish', true),
-- TEXTUR & GEFÜHL
('Ölig', 'texture', true),
('Viskos', 'texture', true),
('Trocken', 'texture', true),
('Adstringierend', 'texture', true),
('Wärmend', 'texture', true),
('Scharf', 'texture', true),
('Beißend', 'texture', true),
('Weich', 'texture', true),
('Samtig', 'texture', true),
('Wässrig', 'texture', true)
ON CONFLICT (name, category) DO NOTHING;