Remove Turash brand identity and guidelines document

This commit is contained in:
Damir Mukimov 2025-12-14 00:10:20 +01:00
parent 0df4812c82
commit 4a38490104
No known key found for this signature in database
GPG Key ID: 42996CC7C73BC750
550 changed files with 45590 additions and 51277 deletions

1065
ADMIN_PANEL_CONCEPT.md Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,753 @@
# Community Features Proposal: Making Turash/Tugan Yak a Daily-Use Tool
## Executive Summary
This document proposes community-focused features and services that will transform Turash from a B2B resource matching platform into a comprehensive community engagement tool. These features aim to drive regular daily usage, increase platform stickiness, and create value for both businesses and citizens.
---
## Current State Analysis
### Existing Features
- ✅ Business/Organization registration and profiles
- ✅ Resource matching engine (heat, water, waste, by-products)
- ✅ Interactive map view with organizations
- ✅ Heritage buildings section
- ✅ Admin panel for content management
- ✅ Basic public pages (About, Contact, Privacy)
### Gaps for Community Engagement
- ❌ No citizen/community member features
- ❌ Limited public engagement beyond business matching
- ❌ No regular-use features for non-business users
- ❌ Missing community-driven content and interactions
- ❌ No local news, events, or community information
---
## Proposed Feature Categories
### 1. Community Impact Dashboard & Transparency
**Purpose**: Show citizens the environmental and economic impact of industrial symbiosis in their city.
#### Features:
- **Real-time Impact Metrics**
- Total CO₂ saved (tonnes)
- Total waste diverted from landfills (tonnes)
- Total energy saved (kWh)
- Total cost savings for local businesses (€)
- Number of active connections
- Visual indicators: "Today we saved X tonnes of CO₂"
- **Impact Map Visualization**
- Interactive map showing resource flows between businesses
- Color-coded connections by resource type
- Filter by: resource type, sector, impact level
- Animated flows showing active exchanges
- **Neighborhood Impact Score**
- Each neighborhood/district gets an "Impact Score"
- Based on: number of connections, resource flows, environmental savings
- Leaderboard of most sustainable districts
- Gamification: badges for districts reaching milestones
- **Success Stories & Case Studies**
- Public-facing success stories (with business permission)
- Before/after metrics
- Video testimonials
- "This Month's Top Connection" feature
**Why This Works**:
- Creates transparency and trust
- Citizens can see tangible environmental benefits
- Encourages businesses to participate (social proof)
- Regular updates drive return visits
---
### 2. Community Resource Sharing Hub
**Purpose**: Extend resource sharing beyond businesses to include community members, creating a circular economy marketplace.
#### Features:
- **Community Resource Exchange**
- Citizens can list: surplus materials, tools, equipment, food
- Categories: Tools, Furniture, Electronics, Building Materials, Food, Textiles
- Search and filter by location, category, condition
- Integration with business resources (citizens can see business surplus)
- **Freecycle/Sharing Economy**
- "Give Away" section for items to donate
- "Wanted" section for community needs
- Community groups for specific neighborhoods
- Rating system for transactions
- **Community Tool Library**
- Shared tools database (power tools, garden equipment, etc.)
- Reservation system
- Location-based pickup/dropoff points
- Integration with local libraries or community centers
- **Food Sharing Network**
- Surplus food from businesses (restaurants, cafes, grocery stores)
- Community garden produce sharing
- Food rescue coordination
- Integration with local food banks
**Why This Works**:
- Daily-use feature for citizens
- Reduces waste, supports circular economy
- Builds community connections
- Natural extension of business resource matching
---
### 3. Local News & Community Information Hub
**Purpose**: Become the go-to source for local sustainability and business news.
#### Features:
- **Sustainability News Feed**
- Local environmental news
- Business sustainability initiatives
- New connections and partnerships
- Government sustainability policies
- Curated from multiple sources (RSS feeds, manual posts)
- **Community Events Calendar**
- Sustainability workshops and events
- Business networking events
- Environmental awareness campaigns
- Community clean-up days
- Integration with local event calendars
- **Local Business Spotlight**
- Featured sustainable businesses
- New business registrations
- Business sustainability achievements
- "Business of the Month" feature
- **City Sustainability Reports**
- Monthly/quarterly sustainability reports
- Progress toward city sustainability goals
- Comparison with other cities
- Downloadable PDF reports
**Why This Works**:
- Regular content updates drive daily visits
- Positions platform as community information hub
- Increases SEO and discoverability
- Builds authority in sustainability space
---
### 4. Citizen Science & Environmental Monitoring
**Purpose**: Engage citizens in data collection and environmental monitoring.
#### Features:
- **Air Quality Reporting**
- Citizens can report air quality observations
- Integration with official air quality sensors (if available)
- Map visualization of air quality by area
- Historical trends and comparisons
- **Waste & Litter Reporting**
- Report illegal dumping sites
- Report overflowing bins
- Photo uploads with geolocation
- Integration with city services for cleanup
- **Water Quality Monitoring**
- Citizen reports on water quality (rivers, lakes)
- Visual observations (color, smell, debris)
- Integration with official monitoring data
- Historical tracking
- **Biodiversity Observations**
- Citizen science: report plant/animal sightings
- Track biodiversity changes over time
- Integration with iNaturalist or similar platforms
- Community challenges (e.g., "Spring Bird Count")
- **Environmental Issue Tracking**
- Report environmental concerns
- Track resolution status
- Public dashboard of reported issues
- Integration with city environmental department
**Why This Works**:
- Engages environmentally conscious citizens
- Creates valuable data for city planning
- Regular participation opportunities
- Builds sense of community ownership
---
### 5. Community Forums & Discussion Spaces
**Purpose**: Create spaces for community dialogue about sustainability and local issues.
#### Features:
- **Sustainability Discussion Forums**
- Topics: Circular economy, waste reduction, energy efficiency
- Business-citizen dialogue
- Expert Q&A sessions
- Best practices sharing
- **Neighborhood Groups**
- Location-based discussion groups
- Neighborhood-specific sustainability initiatives
- Local event planning
- Resource sharing coordination
- **Business-Citizen Dialogue**
- Citizens can ask questions to businesses
- Businesses can share their sustainability stories
- Feedback mechanism for business practices
- Transparency and accountability
- **Expert Knowledge Base**
- Q&A with sustainability experts
- How-to guides (composting, energy saving, etc.)
- Resource matching tips
- Community-contributed content
**Why This Works**:
- Builds community around platform
- Increases time spent on platform
- Creates valuable content archive
- Fosters relationships between stakeholders
---
### 6. Educational Resources & Learning Hub
**Purpose**: Educate community about circular economy, sustainability, and industrial symbiosis.
#### Features:
- **Interactive Learning Modules**
- "What is Industrial Symbiosis?" (animated explainer)
- "How Circular Economy Works" (interactive diagrams)
- "Your Role in Sustainability" (personalized content)
- Progress tracking and certificates
- **Video Library**
- Educational videos about sustainability
- Case studies and success stories
- How-to videos (composting, energy saving, etc.)
- Webinar recordings
- **Resource Guides**
- "How to Reduce Your Business Waste"
- "Finding Resource Matches: A Guide"
- "Starting a Community Garden"
- Downloadable PDF guides
- **Sustainability Calculator**
- Personal carbon footprint calculator
- Business waste reduction calculator
- Energy savings calculator
- "If everyone in your neighborhood did X..." scenarios
- **Kids & Schools Section**
- Educational content for children
- School project resources
- "Sustainability Heroes" stories
- Interactive games and quizzes
**Why This Works**:
- Positions platform as educational resource
- Increases time on site
- Builds long-term engagement
- Creates shareable content
---
### 7. Community Challenges & Gamification
**Purpose**: Motivate participation through challenges, competitions, and rewards.
#### Features:
- **Monthly Sustainability Challenges**
- "Zero Waste Week"
- "Energy Saving Month"
- "Community Clean-up Day"
- "Business Connection Challenge"
- **Leaderboards**
- Most active citizens
- Most sustainable businesses
- Most resource connections
- Neighborhood rankings
- **Badges & Achievements**
- "First Report" badge
- "10 Connections" badge
- "Community Helper" badge
- "Sustainability Champion" badge
- **Rewards Program**
- Points for participation
- Redeemable rewards (local business discounts, etc.)
- Partnership with local businesses for rewards
- Recognition on platform
- **Team Challenges**
- Neighborhood vs. neighborhood competitions
- Business teams
- School competitions
- Community groups
**Why This Works**:
- Increases engagement and retention
- Creates social motivation
- Encourages regular participation
- Builds community spirit
---
### 8. Local Business Directory & Support
**Purpose**: Support local economy while promoting sustainability.
#### Features:
- **Sustainable Business Directory**
- All businesses on platform (with sustainability badges)
- Filter by: sustainability practices, certifications, resource types
- Business profiles with sustainability metrics
- Reviews and ratings
- **"Shop Local, Shop Sustainable" Campaign**
- Featured sustainable businesses
- Special offers from platform businesses
- "Business of the Week" spotlight
- Integration with local business associations
- **Business Sustainability Certifications**
- Platform-issued sustainability badges
- Based on: resource connections, waste reduction, certifications
- Display on business profiles
- Verification process
- **Business-Citizen Partnerships**
- Citizens can "follow" businesses
- Get updates on sustainability initiatives
- Support local businesses through platform
- Feedback and suggestions
**Why This Works**:
- Supports local economy
- Creates value for businesses beyond matching
- Increases business participation
- Builds community-business relationships
---
### 9. Volunteer & Community Action Coordination
**Purpose**: Facilitate community organizing and volunteer coordination.
#### Features:
- **Volunteer Opportunities Board**
- Environmental cleanup events
- Community garden projects
- Sustainability workshops
- Business sustainability audits (volunteer experts)
- **Event Organization Tools**
- Create community events
- RSVP system
- Volunteer sign-up
- Resource needs (tools, materials, etc.)
- **Community Projects**
- Crowdfunding for sustainability projects
- Project proposals and voting
- Progress tracking
- Success stories
- **Skill Sharing**
- Citizens offer skills (e.g., composting expert, energy auditor)
- Businesses offer workshops
- Skill exchange marketplace
- Mentorship programs
**Why This Works**:
- Builds active community
- Creates offline engagement
- Strengthens platform value
- Encourages regular participation
---
### 10. Mobile App Features (Future)
**Purpose**: Enable on-the-go access and location-based features.
#### Features:
- **Push Notifications**
- New matches for your business
- Community events nearby
- Environmental alerts
- Impact milestones
- **Location-Based Features**
- "Nearby Resources" when walking around
- "Report Issue" with photo and location
- "Find Sustainable Businesses" nearby
- AR features (overlay resource flows on map)
- **Quick Actions**
- Quick resource listing
- One-tap match request
- Quick issue reporting
- Event check-in
- **Offline Capabilities**
- View saved content offline
- Draft reports offline
- Sync when online
**Why This Works**:
- Increases accessibility
- Enables real-time engagement
- Location-based features add value
- Mobile-first users
---
## Implementation Priority
### Phase 1: Foundation (Weeks 1-4)
**Goal**: Add basic community features to drive initial engagement
1. **Community Impact Dashboard**
- Real-time impact metrics
- Basic impact map
- Success stories section
2. **Local News Feed**
- Sustainability news aggregation
- Community events calendar
- Business spotlight
3. **Community Resource Sharing (Basic)**
- Simple listing system
- Basic search and filter
- Contact mechanism
**Expected Outcome**:
- 50-100 daily active users (citizens)
- 2-3 community resource exchanges per week
- Increased time on site
---
### Phase 2: Engagement (Weeks 5-8)
**Goal**: Increase engagement and build community
4. **Community Forums**
- Basic discussion forums
- Neighborhood groups
- Business-citizen dialogue
5. **Citizen Science Features**
- Environmental issue reporting
- Basic air quality reporting
- Issue tracking dashboard
6. **Educational Resources**
- Learning modules
- Video library
- Resource guides
**Expected Outcome**:
- 200-300 daily active users
- Active forum discussions
- Regular citizen reports
---
### Phase 3: Advanced Features (Weeks 9-12)
**Goal**: Create comprehensive community platform
7. **Gamification**
- Challenges and badges
- Leaderboards
- Rewards program
8. **Volunteer Coordination**
- Volunteer board
- Event organization tools
- Skill sharing
9. **Advanced Resource Sharing**
- Tool library
- Food sharing network
- Community groups
**Expected Outcome**:
- 500+ daily active users
- Active community participation
- Regular challenges and events
---
### Phase 4: Mobile & Advanced (Months 4-6)
**Goal**: Mobile access and advanced features
10. **Mobile App**
- iOS and Android apps
- Push notifications
- Location-based features
11. **Advanced Analytics**
- Personal impact tracking
- Neighborhood analytics
- Predictive insights
12. **Integration Features**
- Social media integration
- Third-party data sources
- API for external developers
**Expected Outcome**:
- 1000+ daily active users
- Mobile-first engagement
- Platform becomes essential tool
---
## Technical Considerations
### New Backend Endpoints Needed
```
/api/v1/community/
/impact
GET /metrics # Impact metrics
GET /map # Impact map data
GET /stories # Success stories
/resources
GET /listings # Community resource listings
POST /listings # Create listing
GET /listings/:id # Get listing
PUT /listings/:id # Update listing
DELETE /listings/:id # Delete listing
/news
GET /feed # News feed
POST /articles # Create article (admin)
GET /articles/:id # Get article
/events
GET /calendar # Events calendar
POST /events # Create event
GET /events/:id # Get event
POST /events/:id/rsvp # RSVP to event
/reports
POST /environmental # Submit environmental report
GET /reports # List reports
GET /reports/:id # Get report
PUT /reports/:id/status # Update report status
/forums
GET /topics # List forum topics
POST /topics # Create topic
GET /topics/:id # Get topic with posts
POST /topics/:id/posts # Add post
/challenges
GET /active # Active challenges
GET /leaderboard # Leaderboards
POST /participate # Join challenge
GET /badges # User badges
```
### Database Schema Additions
```sql
-- Community resource listings
CREATE TABLE community_listings (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
title VARCHAR(255) NOT NULL,
description TEXT,
category VARCHAR(50),
location POINT,
status VARCHAR(20),
created_at TIMESTAMP,
updated_at TIMESTAMP
);
-- Environmental reports
CREATE TABLE environmental_reports (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
type VARCHAR(50),
description TEXT,
location POINT,
photos TEXT[],
status VARCHAR(20),
created_at TIMESTAMP
);
-- Forum topics and posts
CREATE TABLE forum_topics (
id UUID PRIMARY KEY,
title VARCHAR(255),
category VARCHAR(50),
user_id UUID REFERENCES users(id),
created_at TIMESTAMP
);
CREATE TABLE forum_posts (
id UUID PRIMARY KEY,
topic_id UUID REFERENCES forum_topics(id),
user_id UUID REFERENCES users(id),
content TEXT,
created_at TIMESTAMP
);
-- Community events
CREATE TABLE community_events (
id UUID PRIMARY KEY,
title VARCHAR(255),
description TEXT,
location POINT,
start_time TIMESTAMP,
end_time TIMESTAMP,
organizer_id UUID REFERENCES users(id),
created_at TIMESTAMP
);
-- User badges and achievements
CREATE TABLE user_badges (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
badge_type VARCHAR(50),
earned_at TIMESTAMP
);
```
---
## Success Metrics
### Engagement Metrics
- **Daily Active Users (DAU)**: Target 1000+ by month 6
- **Monthly Active Users (MAU)**: Target 5000+ by month 6
- **Average Session Duration**: Target 5+ minutes
- **Pages per Session**: Target 4+ pages
- **Return Visitor Rate**: Target 40%+
### Community Metrics
- **Community Resource Listings**: Target 100+ active listings
- **Environmental Reports**: Target 50+ reports per month
- **Forum Posts**: Target 200+ posts per month
- **Event RSVPs**: Target 500+ RSVPs per month
- **Challenge Participants**: Target 200+ participants per challenge
### Business Impact Metrics
- **Business Registrations**: Increase by 30% due to community visibility
- **Resource Connections**: Increase by 25% due to community engagement
- **Business-Citizen Interactions**: Track dialogue and feedback
---
## Monetization Opportunities
### Community Features (Free)
- Basic community features remain free to drive engagement
- Creates network effects and platform value
### Premium Community Features (Optional)
- **Premium Citizen Accounts**: €5/month
- Advanced analytics
- Priority support
- Ad-free experience
- Exclusive content
### Business Opportunities
- **Sponsored Content**: Businesses can sponsor news articles
- **Featured Listings**: Businesses can pay for featured placement
- **Event Sponsorship**: Businesses can sponsor community events
- **Advertising**: Local business ads in community sections
---
## Competitive Advantages
1. **Unique Position**: Only platform combining B2B resource matching with community engagement
2. **Data Advantage**: Rich data from business resource flows enables better community features
3. **Network Effects**: More businesses = better community data = more citizen engagement
4. **Local Focus**: Hyper-local approach creates stronger community bonds
5. **Sustainability Authority**: Positioned as local sustainability hub
---
## Risks & Mitigations
### Risk 1: Low Initial Engagement
**Mitigation**:
- Start with high-value features (Impact Dashboard, News)
- Partner with local organizations for content
- Active community management
### Risk 2: Content Moderation Burden
**Mitigation**:
- Automated moderation tools
- Community moderators
- Clear community guidelines
- Reporting mechanisms
### Risk 3: Feature Bloat
**Mitigation**:
- Phased rollout
- User feedback loops
- Analytics-driven feature prioritization
- Regular feature audits
### Risk 4: Resource Requirements
**Mitigation**:
- Start with MVP features
- Leverage existing infrastructure
- Community-contributed content
- Volunteer moderators
---
## Next Steps
1. **User Research**: Survey citizens and businesses about desired features
2. **MVP Planning**: Define Phase 1 features in detail
3. **Design Mockups**: Create UI/UX designs for key features
4. **Technical Architecture**: Design database schema and API endpoints
5. **Pilot Program**: Launch Phase 1 features in one neighborhood
6. **Iterate**: Gather feedback and iterate based on usage data
---
## Conclusion
By adding community-focused features, Turash/Tugan Yak can transform from a B2B matching platform into a comprehensive community engagement tool. These features will:
- **Drive Regular Usage**: Daily-use features keep users coming back
- **Build Community**: Forums, events, and challenges create engagement
- **Increase Platform Value**: More users = better network effects
- **Support Local Economy**: Business directory and support features
- **Promote Sustainability**: Educational and engagement features
The phased approach allows for iterative development, user feedback, and resource management while building toward a comprehensive community platform.
---
**Document Version**: 1.0
**Last Updated**: 2025-01-27
**Author**: AI Assistant
**Status**: Proposal - Awaiting Review

View File

@ -0,0 +1,418 @@
# Community Features: Quick Wins & Implementation Guide
## Top 5 Quick Wins (Can Implement in 1-2 Weeks Each)
### 1. Community Impact Dashboard ⭐⭐⭐⭐⭐
**Effort**: Medium | **Impact**: High | **Engagement**: Daily
**What to Build**:
- Public dashboard showing:
- Total CO₂ saved (tonnes)
- Total waste diverted (tonnes)
- Total energy saved (kWh)
- Number of active connections
- Total cost savings (€)
**Implementation**:
- Aggregate data from existing resource flows
- Create `/community/impact` page
- Add real-time counter animations
- Show "Last updated" timestamp
**Why It Works**:
- Transparent, shareable metrics
- Creates social proof for businesses
- Citizens can see tangible benefits
- Low maintenance (auto-updates from existing data)
---
### 2. Success Stories Section ⭐⭐⭐⭐⭐
**Effort**: Low | **Impact**: High | **Engagement**: Weekly
**What to Build**:
- Public page showcasing successful connections
- Each story includes:
- Business names (with permission)
- Before/after metrics
- Photos/videos
- Quotes from businesses
- Resource type and savings
**Implementation**:
- Create `/community/success-stories` page
- Admin can add/edit stories via admin panel
- Simple card-based layout
- Share buttons for social media
**Why It Works**:
- Social proof drives business signups
- Shareable content for marketing
- Builds trust and credibility
- Low effort, high value
---
### 3. Local Sustainability News Feed ⭐⭐⭐⭐
**Effort**: Medium | **Impact**: Medium | **Engagement**: Daily
**What to Build**:
- News feed on homepage or dedicated page
- Content types:
- New business registrations
- New connections made
- Sustainability events
- Local environmental news (RSS aggregation)
- Platform updates
**Implementation**:
- Create `/community/news` page
- Admin can post articles via admin panel
- RSS feed integration for external news
- Simple blog-style layout
- Email newsletter option (future)
**Why It Works**:
- Regular content updates drive return visits
- Positions platform as information hub
- SEO benefits
- Low maintenance with RSS feeds
---
### 4. Community Events Calendar ⭐⭐⭐⭐
**Effort**: Medium | **Impact**: Medium | **Engagement**: Weekly
**What to Build**:
- Public calendar of sustainability events
- Event types:
- Workshops
- Networking events
- Community clean-ups
- Business sustainability events
- Platform-organized events
**Implementation**:
- Create `/community/events` page
- Admin can add events via admin panel
- Calendar view (monthly/weekly)
- Event detail pages
- RSVP functionality (basic)
**Why It Works**:
- Drives offline engagement
- Builds community
- Regular updates needed
- Can partner with local organizations
---
### 5. Simple Resource Sharing (MVP) ⭐⭐⭐
**Effort**: High | **Impact**: High | **Engagement**: Daily
**What to Build**:
- Basic listing system for community members
- Users can:
- List surplus items (free/for sale)
- Search listings by category/location
- Contact lister (via platform messaging)
- Mark items as taken/sold
**Implementation**:
- Create `/community/resources` page
- User authentication required
- Simple form to create listing
- Basic search and filter
- Contact form (email or in-app message)
**Why It Works**:
- Daily-use feature
- Extends platform beyond B2B
- Builds community connections
- Natural extension of business matching
---
## Implementation Priority Matrix
```
High Impact, Low Effort (Do First):
✅ Success Stories
✅ Impact Dashboard (basic version)
High Impact, Medium Effort (Do Second):
✅ News Feed
✅ Events Calendar
High Impact, High Effort (Plan for Phase 2):
⏳ Resource Sharing (full version)
⏳ Forums
⏳ Citizen Science
Medium Impact, Low Effort (Nice to Have):
💡 Business Directory Enhancement
💡 Educational Resources (basic)
```
---
## Technical Quick Start Guide
### 1. Impact Dashboard Implementation
**Frontend** (`/bugulma/frontend/pages/CommunityImpactPage.tsx`):
```typescript
// New page component
// Fetch metrics from API
// Display with cards and charts
// Add to routing
```
**Backend** (`/bugulma/backend/internal/routes/community.go`):
```go
// New route group: /api/v1/community
// Endpoint: GET /api/v1/community/impact
// Aggregate data from:
// - Resource flows (calculate savings)
// - Organizations (count connections)
// - Proposals (count successful matches)
```
**Database**:
- Use existing tables (no new schema needed)
- Aggregate queries on resource_flows, organizations, proposals
---
### 2. Success Stories Implementation
**Frontend** (`/bugulma/frontend/pages/SuccessStoriesPage.tsx`):
```typescript
// New page component
// Card-based layout
// Filter by resource type, sector
// Share buttons
```
**Backend**:
```go
// New table: success_stories
// Endpoints:
// GET /api/v1/community/stories
// POST /api/v1/admin/stories (admin only)
// PUT /api/v1/admin/stories/:id
```
**Admin Panel**:
- Add to admin content management
- Simple form: title, description, metrics, images, business IDs
---
### 3. News Feed Implementation
**Frontend** (`/bugulma/frontend/pages/CommunityNewsPage.tsx`):
```typescript
// New page component
// Blog-style layout
// Pagination
// Categories/tags
```
**Backend**:
```go
// Reuse announcements table or create news_articles
// Endpoints:
// GET /api/v1/community/news
// POST /api/v1/admin/news (admin only)
// RSS feed parser (optional)
```
**Admin Panel**:
- Extend announcements or create news management
- Rich text editor
- Image upload
- Publish/draft status
---
## Database Schema Additions (Minimal)
```sql
-- Success stories
CREATE TABLE success_stories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL,
description TEXT,
business_ids UUID[], -- Array of business IDs involved
metrics JSONB, -- {co2_saved, waste_diverted, cost_saved, etc}
images TEXT[],
published BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Community news/articles
CREATE TABLE community_news (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL,
content TEXT,
category VARCHAR(50),
author_id UUID REFERENCES users(id),
published BOOLEAN DEFAULT false,
published_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Community events
CREATE TABLE community_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL,
description TEXT,
location VARCHAR(255),
latitude DECIMAL(10, 8),
longitude DECIMAL(11, 8),
start_time TIMESTAMP,
end_time TIMESTAMP,
organizer_id UUID REFERENCES users(id),
published BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
```
---
## Frontend Routing Additions
Add to `AppRouter.tsx`:
```typescript
<Route path="/community/impact" element={<CommunityImpactPage />} />
<Route path="/community/stories" element={<SuccessStoriesPage />} />
<Route path="/community/news" element={<CommunityNewsPage />} />
<Route path="/community/events" element={<CommunityEventsPage />} />
```
Update navigation in `TopBar.tsx` or `Footer.tsx`:
```typescript
<NavLink to="/community/impact">Impact</NavLink>
<NavLink to="/community/stories">Success Stories</NavLink>
<NavLink to="/community/news">News</NavLink>
<NavLink to="/community/events">Events</NavLink>
```
---
## Content Strategy
### Initial Content to Create:
1. **Impact Dashboard**:
- Auto-populate from existing data
- Add explanatory text about what metrics mean
2. **Success Stories** (3-5 initial stories):
- Interview businesses with successful connections
- Get permission to feature them
- Create compelling narratives with metrics
3. **News Feed** (5-10 initial articles):
- "Welcome to Turash" introduction
- "How Industrial Symbiosis Works" explainer
- Feature new businesses joining
- Local sustainability news (curated)
4. **Events Calendar**:
- Platform launch event
- First business networking event
- Sustainability workshop (partner with local org)
---
## Marketing & Promotion
### Launch Strategy:
1. **Email Campaign**:
- Announce new community features to existing users
- Highlight impact dashboard and success stories
2. **Social Media**:
- Share impact metrics regularly
- Feature success stories
- Promote events
3. **Local Partnerships**:
- Partner with environmental organizations
- Cross-promote events
- Guest content from experts
4. **Press Release**:
- "Turash Launches Community Impact Dashboard"
- Highlight environmental benefits
- Include success stories
---
## Success Metrics to Track
### Week 1-2:
- Page views on new community pages
- Time spent on impact dashboard
- Social shares of success stories
### Month 1:
- Return visitors to community pages
- Newsletter signups (if added)
- Event RSVPs
- User feedback
### Month 3:
- Daily active users on community features
- Content engagement (comments, shares)
- Business inquiries from community visibility
---
## Next Steps Checklist
- [ ] Review and approve feature priorities
- [ ] Design mockups for Impact Dashboard
- [ ] Create database migrations for new tables
- [ ] Implement Impact Dashboard backend endpoint
- [ ] Build Impact Dashboard frontend page
- [ ] Create 3-5 initial success stories
- [ ] Implement Success Stories page
- [ ] Set up News Feed (basic version)
- [ ] Create Events Calendar page
- [ ] Add navigation links to community pages
- [ ] Test all new features
- [ ] Launch and promote
---
## Resources Needed
### Development:
- 1-2 weeks for Impact Dashboard
- 1 week for Success Stories
- 1-2 weeks for News Feed
- 1 week for Events Calendar
- **Total**: 4-6 weeks for all quick wins
### Content:
- 1 content writer for success stories
- 1 person for news curation
- 1 person for event coordination
### Design:
- UI/UX design for new pages
- Graphics for impact metrics
- Social media assets
---
**Last Updated**: 2025-01-27
**Status**: Ready for Implementation

View File

@ -0,0 +1,375 @@
# Community Features Status Report
**Date**: 2025-01-27
**Status**: Partial Implementation - Most Features Missing
---
## Executive Summary
The community features proposal outlined 10 major feature categories to transform the platform from B2B-only to a comprehensive community engagement tool. Currently, **only a minimal foundation exists** - specifically, the community listing search functionality is partially implemented. **All other planned community features are missing**.
---
## What Was Planned
Based on `COMMUNITY_FEATURES_PROPOSAL.md` and `COMMUNITY_FEATURES_QUICK_WINS.md`, the following features were proposed:
### Phase 1: Foundation (Priority)
1. ✅ **Community Impact Dashboard** - Real-time impact metrics (CO₂ saved, waste diverted, etc.)
2. ✅ **Success Stories Section** - Public showcase of successful connections
3. ✅ **Local Sustainability News Feed** - News aggregation and articles
4. ✅ **Community Events Calendar** - Public calendar of sustainability events
5. ✅ **Community Resource Sharing (Basic)** - Simple listing system for citizens
### Phase 2-4: Advanced Features
6. Community Forums & Discussion Spaces
7. Citizen Science & Environmental Monitoring
8. Educational Resources & Learning Hub
9. Community Challenges & Gamification
10. Volunteer & Community Action Coordination
11. Mobile App Features
---
## Current Implementation Status
### ✅ **IMPLEMENTED** (Partial)
#### 1. Community Listing Domain Model (Backend)
**Location**: `bugulma/backend/internal/domain/community_listing.go`
- ✅ Complete domain model with all fields
- ✅ Types: product, service, tool, skill, need
- ✅ Price types: free, sale, rent, trade, borrow
- ✅ Status management: active, reserved, completed, archived
- ✅ Location support with PostGIS
- ✅ Validation logic
#### 2. Community Listing Repository (Backend)
**Location**: `bugulma/backend/internal/repository/community_listing_repository.go`
- ✅ CRUD operations
- ✅ Search with location
- ✅ Get by user, type, category
- ✅ Spatial queries (PostGIS support)
#### 3. Community Listing Search API (Backend)
**Location**: `bugulma/backend/internal/handler/discovery_handler.go`
- ✅ `GET /api/v1/discovery/community` - Search community listings
- ✅ Integrated into universal search
- ✅ Query parameters: query, categories, location, radius, price, tags
#### 4. Discovery Page (Frontend)
**Location**: `bugulma/frontend/pages/DiscoveryPage.tsx`
- ✅ Search interface for products, services, and community listings
- ✅ Tabbed view showing community listings
- ✅ Display of community listing cards
- ✅ Integration with discovery API
#### 5. Discovery API Service (Frontend)
**Location**: `bugulma/frontend/services/discovery-api.ts`
- ✅ `searchCommunity()` function
- ✅ TypeScript interfaces for CommunityListing
- ✅ Query building utilities
---
### ❌ **NOT IMPLEMENTED** (Critical Missing Features)
#### 1. Community Listing Creation (Backend)
**Status**: Endpoint exists but returns 501 Not Implemented
**Location**: `bugulma/backend/internal/handler/discovery_handler.go:308-312`
```go
func (h *DiscoveryHandler) CreateCommunityListing(c *gin.Context) {
// TODO: Implement community listing creation
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not yet implemented"})
}
```
**Impact**: Users cannot create community listings through the API.
---
#### 2. Community Impact Dashboard
**Status**: ❌ Not Implemented
**Planned**:
- Public dashboard at `/community/impact`
- Real-time metrics: CO₂ saved, waste diverted, energy saved, cost savings
- Impact map visualization
- Success stories integration
**Missing**:
- Backend endpoint: `GET /api/v1/community/impact`
- Frontend page: `CommunityImpactPage.tsx`
- Route in `AppRouter.tsx`
- Navigation links
---
#### 3. Success Stories Section
**Status**: ❌ Not Implemented
**Planned**:
- Public page at `/community/stories`
- Admin can add/edit stories
- Card-based layout with metrics, images, quotes
**Missing**:
- Database table: `success_stories`
- Backend endpoints: `GET /api/v1/community/stories`, `POST /api/v1/admin/stories`
- Frontend page: `SuccessStoriesPage.tsx`
- Route in `AppRouter.tsx`
- Admin panel integration
---
#### 4. Local Sustainability News Feed
**Status**: ❌ Not Implemented
**Planned**:
- Public page at `/community/news`
- Blog-style layout
- Admin can post articles
- RSS feed integration (optional)
**Missing**:
- Database table: `community_news` (or reuse announcements)
- Backend endpoints: `GET /api/v1/community/news`, `POST /api/v1/admin/news`
- Frontend page: `CommunityNewsPage.tsx`
- Route in `AppRouter.tsx`
- Admin content management
---
#### 5. Community Events Calendar
**Status**: ❌ Not Implemented
**Planned**:
- Public page at `/community/events`
- Calendar view (monthly/weekly)
- Event detail pages
- RSVP functionality
**Missing**:
- Database table: `community_events`
- Backend endpoints: `GET /api/v1/community/events`, `POST /api/v1/community/events/:id/rsvp`
- Frontend page: `CommunityEventsPage.tsx`
- Route in `AppRouter.tsx`
- Calendar component integration
---
#### 6. Community Resource Sharing (Full Implementation)
**Status**: ⚠️ Partially Implemented
**What Exists**:
- Search functionality (read-only)
- Domain model and repository
**What's Missing**:
- Create listing functionality (backend handler not implemented)
- Frontend form to create listings
- User authentication/authorization for creation
- Edit/delete functionality
- Contact/messaging system
---
#### 7. All Other Phase 2-4 Features
**Status**: ❌ Not Implemented
- Community Forums
- Citizen Science & Environmental Monitoring
- Educational Resources
- Gamification & Challenges
- Volunteer Coordination
- Mobile App Features
---
## Database Schema Status
### ✅ **EXISTS**
- `community_listings` table (implied by domain model, but migration not verified)
### ❌ **MISSING** (Required for Phase 1)
- `success_stories` table
- `community_news` table (or reuse `announcements`)
- `community_events` table
### ❌ **MISSING** (Required for Phase 2+)
- `environmental_reports` table
- `forum_topics` table
- `forum_posts` table
- `user_badges` table
- `challenge_participations` table
---
## Backend API Endpoints Status
### ✅ **IMPLEMENTED**
```
GET /api/v1/discovery/community # Search community listings
```
### ❌ **MISSING** (Phase 1 Priority)
```
POST /api/v1/discovery/community # Create community listing (501 Not Implemented)
GET /api/v1/community/impact # Impact metrics
GET /api/v1/community/stories # Success stories
POST /api/v1/admin/stories # Create story (admin)
GET /api/v1/community/news # News feed
POST /api/v1/admin/news # Create article (admin)
GET /api/v1/community/events # Events calendar
POST /api/v1/community/events # Create event
POST /api/v1/community/events/:id/rsvp # RSVP to event
```
### ❌ **MISSING** (Phase 2+)
```
POST /api/v1/community/reports/environmental # Submit environmental report
GET /api/v1/community/reports # List reports
GET /api/v1/community/forums/topics # Forum topics
POST /api/v1/community/forums/topics # Create topic
GET /api/v1/community/challenges/active # Active challenges
GET /api/v1/community/challenges/leaderboard # Leaderboards
```
---
## Frontend Routes Status
### ✅ **EXISTS**
```
/discovery # Discovery page (includes community search)
```
### ❌ **MISSING** (Phase 1 Priority)
```
/community/impact # Impact Dashboard
/community/stories # Success Stories
/community/news # News Feed
/community/events # Events Calendar
/community/resources # Resource Sharing (full)
```
---
## Navigation & UI Status
### ❌ **MISSING**
- No navigation links to community pages in `TopBar.tsx` or `Footer.tsx`
- No community section in main navigation
- No community-related UI components (beyond discovery page)
---
## Implementation Gaps Summary
### Critical Gaps (Phase 1)
1. **Community Listing Creation** - Backend handler returns 501
2. **Impact Dashboard** - No backend endpoint, no frontend page
3. **Success Stories** - No database table, no endpoints, no frontend
4. **News Feed** - No database table, no endpoints, no frontend
5. **Events Calendar** - No database table, no endpoints, no frontend
### High Priority Gaps
6. **Database Migrations** - Missing tables for success_stories, community_news, community_events
7. **Backend Routes** - No `/api/v1/community/*` route group
8. **Frontend Routes** - No `/community/*` routes in AppRouter
9. **Navigation** - No links to community features
10. **Admin Panel** - No content management for stories/news/events
---
## Recommendations
### Immediate Actions (Week 1-2)
1. **Implement Community Listing Creation**
- Complete `CreateCommunityListing` handler
- Add authentication/authorization
- Create frontend form component
- Add route for creating listings
2. **Create Database Migrations**
- `success_stories` table
- `community_news` table
- `community_events` table
3. **Implement Impact Dashboard (MVP)**
- Backend endpoint aggregating existing data
- Simple frontend page with metrics cards
- Add route and navigation link
### Short-term (Weeks 3-4)
4. **Success Stories Section**
- Backend CRUD endpoints
- Frontend page with card layout
- Admin panel integration
5. **News Feed (Basic)**
- Backend endpoints
- Frontend blog-style page
- Admin content management
6. **Events Calendar (Basic)**
- Backend endpoints
- Frontend calendar view
- Basic RSVP functionality
### Medium-term (Months 2-3)
7. Complete Phase 2 features (Forums, Citizen Science, Education)
8. Add gamification and challenges
9. Implement volunteer coordination
---
## Files to Create/Modify
### Backend
```
bugulma/backend/internal/handler/community_handler.go # NEW
bugulma/backend/internal/routes/community.go # NEW
bugulma/backend/migrations/postgres/020_community_features.up.sql # NEW
bugulma/backend/internal/handler/discovery_handler.go # MODIFY (implement CreateCommunityListing)
bugulma/backend/internal/routes/routes.go # MODIFY (add community routes)
```
### Frontend
```
bugulma/frontend/pages/CommunityImpactPage.tsx # NEW
bugulma/frontend/pages/SuccessStoriesPage.tsx # NEW
bugulma/frontend/pages/CommunityNewsPage.tsx # NEW
bugulma/frontend/pages/CommunityEventsPage.tsx # NEW
bugulma/frontend/services/community-api.ts # NEW
bugulma/frontend/components/community/ # NEW (various components)
bugulma/frontend/src/AppRouter.tsx # MODIFY (add routes)
bugulma/frontend/components/layout/TopBar.tsx # MODIFY (add nav links)
bugulma/frontend/components/layout/Footer.tsx # MODIFY (add nav links)
```
---
## Conclusion
**Current State**: Only ~10% of planned community features are implemented. The foundation exists (domain model, repository, search), but all user-facing features and most backend endpoints are missing.
**Priority**: Focus on Phase 1 features (Impact Dashboard, Success Stories, News, Events) as outlined in `COMMUNITY_FEATURES_QUICK_WINS.md`. These are high-impact, medium-effort features that can drive community engagement.
**Estimated Effort**:
- Phase 1 completion: 4-6 weeks
- Full proposal implementation: 4-6 months
---
**Report Generated**: 2025-01-27
**Next Review**: After Phase 1 implementation

View File

@ -0,0 +1,368 @@
# Community Functionality Workflow & Accessibility Report
**Date**: 2025-01-27
**Issue**: Community features exist but are completely inaccessible to users
---
## Executive Summary
The community listing search functionality has been **partially implemented** (backend API + frontend page), but it is **completely invisible and inaccessible** to users. There are **no navigation links, no UI entry points, and no way to create listings**. Users would need to manually type `/discovery` in the URL to access it, and even then, they cannot create community listings.
---
## Current Implementation Status
### ✅ **What Exists**
1. **Backend API** (`/api/v1/discovery/community`)
- ✅ Search endpoint works
- ✅ Returns community listings
- ✅ Integrated into universal search
2. **Frontend Page** (`/discovery`)
- ✅ DiscoveryPage component exists
- ✅ Shows products, services, and community listings in tabs
- ✅ Search functionality works
- ✅ Route exists in AppRouter
3. **Domain Model & Repository**
- ✅ Complete domain model
- ✅ Repository with CRUD operations
- ✅ Spatial search support
### ❌ **What's Missing (Critical Issues)**
1. **No Navigation Links**
- ❌ No link in TopBar/Header
- ❌ No link in Footer
- ❌ No link on Landing Page
- ❌ No link in Dashboard
- ❌ No link in any menu
2. **No Way to Create Listings**
- ❌ Backend handler returns `501 Not Implemented`
- ❌ No frontend form to create listings
- ❌ No "Create Listing" button anywhere
- ❌ No workflow for users to add their items
3. **No Map Integration**
- ❌ Community listings don't show on map
- ❌ No markers for community listings
- ❌ MapControls has "discovery" button but it only toggles products/services
4. **No User Workflow**
- ❌ No onboarding or explanation
- ❌ No help text or guides
- ❌ No clear use case presentation
---
## Current User Experience (Broken)
### How Users Would Find It (If They Could)
1. **Manual URL Entry**: User must type `/discovery` in browser
2. **Search Results**: If they search for something, they might see community listings in results (but only if they know to look)
### What Happens When They Try to Use It
1. **Search Works**: Users can search and see community listings (if any exist)
2. **Cannot Create**: Clicking anywhere won't let them create a listing
3. **No Context**: No explanation of what community listings are or how to use them
### The Problem
- **Zero discoverability**: Feature is completely hidden
- **Incomplete functionality**: Can search but cannot create
- **No integration**: Not connected to main workflows (map, dashboard, landing page)
---
## Intended Workflow (Based on Code Analysis)
### Discovery Page Purpose
Based on the code structure, the intended workflow appears to be:
1. **User searches** for products/services/community items
2. **Results show** across three categories:
- Products (from businesses)
- Services (from businesses)
- Community listings (from citizens)
3. **User can filter** by location, price, category, etc.
4. **User can view** details of any match
### Missing Workflow Steps
1. ❌ **How users discover the Discovery page**
2. ❌ **How users create community listings**
3. ❌ **How users contact listing owners**
4. ❌ **How community listings appear on map**
5. ❌ **How this integrates with main platform features**
---
## Required Fixes (Priority Order)
### 🔴 **CRITICAL - Make It Discoverable**
#### 1. Add Navigation Links
**Files to Modify**:
- `bugulma/frontend/components/layout/TopBar.tsx` - Add "Discover" link
- `bugulma/frontend/components/layout/Footer.tsx` - Add "Discover" link
- `bugulma/frontend/pages/LandingPage.tsx` - Add section/button for discovery
- `bugulma/frontend/pages/DashboardPage.tsx` - Add quick action button
**Implementation**:
```typescript
// In TopBar or HeaderActions - add navigation item
<NavLink to="/discovery">Discover</NavLink>
// In LandingPage - add section or button
<Button onClick={() => navigate('/discovery')}>
Discover Resources
</Button>
// In DashboardPage - add to quick actions
<Button onClick={() => navigate('/discovery')}>
<Search className="h-4 w-4" />
Discover Resources
</Button>
```
#### 2. Add to Landing Page
**Location**: `bugulma/frontend/pages/LandingPage.tsx`
Add a new section or integrate into existing sections:
- Add "Discover Resources" button in Hero section
- Add new section: "Community Resource Exchange"
- Link from Sectors section
#### 3. Add to Dashboard Quick Actions
**Location**: `bugulma/frontend/pages/DashboardPage.tsx`
Add button to Quick Actions grid:
```typescript
<Button
variant="outline"
onClick={() => navigate('/discovery')}
className="h-20 flex flex-col items-center justify-center gap-2"
>
<Search className="h-4 w-6" />
<span className="text-sm">Discover Resources</span>
</Button>
```
---
### 🟠 **HIGH PRIORITY - Enable Creation**
#### 4. Implement Backend Creation Handler
**File**: `bugulma/backend/internal/handler/discovery_handler.go`
**Current State**:
```go
func (h *DiscoveryHandler) CreateCommunityListing(c *gin.Context) {
// TODO: Implement community listing creation
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not yet implemented"})
}
```
**Required**:
- Implement full handler
- Add authentication/authorization
- Validate input
- Create listing via matching service
#### 5. Create Frontend Form Component
**New File**: `bugulma/frontend/components/community/CreateCommunityListingForm.tsx`
**Features**:
- Form fields for all listing types (product, service, tool, skill, need)
- Location picker
- Image upload
- Category selection
- Price/rate input
- Submit to API
#### 6. Add "Create Listing" Button
**Locations**:
- DiscoveryPage - Add "Create Listing" button at top
- DashboardPage - Add to quick actions
- UserDashboard - Add section for user's listings
---
### 🟡 **MEDIUM PRIORITY - Integration**
#### 7. Map Integration
**Files to Modify**:
- `bugulma/frontend/pages/MapView.tsx` - Add community listing markers
- `bugulma/frontend/components/map/MapControls.tsx` - Add toggle for community listings
- `bugulma/frontend/components/map/ProductServiceMarkers.tsx` - Extend to show community listings
**Implementation**:
- Fetch community listings for map bounds
- Display markers on map
- Show popup with listing details
- Link to discovery page or detail view
#### 8. Search Integration
**File**: `bugulma/frontend/components/ui/SearchBar.tsx`
**Enhancement**:
- When user searches, show option to "Search All Resources"
- Link to discovery page with search query pre-filled
- Show community listings in search suggestions
#### 9. User Dashboard Integration
**File**: `bugulma/frontend/pages/UserDashboard.tsx`
**Add**:
- Section: "My Community Listings"
- List user's listings
- Quick actions: Create, Edit, Delete
- Link to discovery page
---
## Recommended Workflow (After Fixes)
### For Citizens (Community Members)
1. **Discover the Feature**
- See "Discover Resources" button on landing page
- See link in navigation menu
- See quick action on dashboard (if logged in)
2. **Browse Listings**
- Navigate to `/discovery`
- Search for items they need
- Filter by location, category, price
- View details of listings
3. **Create Listing**
- Click "Create Listing" button
- Fill out form (type, category, description, location, price)
- Upload images
- Submit listing
4. **Manage Listings**
- View "My Listings" in dashboard
- Edit or delete listings
- Mark as sold/taken
5. **Contact Owners**
- View listing details
- Contact owner (via platform messaging or email)
- Arrange pickup/delivery
### For Businesses
1. **Discover Community Resources**
- See community listings in discovery search
- Filter to see what citizens are offering
- Contact citizens for resources
2. **Integration with Business Resources**
- Community listings appear alongside business products/services
- Unified search experience
- Can see both business and community resources
---
## Files That Need Changes
### Backend
```
bugulma/backend/internal/handler/discovery_handler.go
- Implement CreateCommunityListing handler (currently returns 501)
```
### Frontend - Navigation
```
bugulma/frontend/components/layout/TopBar.tsx
- Add "Discover" navigation link
bugulma/frontend/components/layout/Footer.tsx
- Add "Discover" footer link
bugulma/frontend/pages/LandingPage.tsx
- Add "Discover Resources" button or section
bugulma/frontend/pages/DashboardPage.tsx
- Add "Discover Resources" quick action button
bugulma/frontend/pages/UserDashboard.tsx
- Add "My Community Listings" section
- Add "Create Listing" button
```
### Frontend - Creation
```
bugulma/frontend/components/community/CreateCommunityListingForm.tsx (NEW)
- Form component for creating listings
bugulma/frontend/pages/DiscoveryPage.tsx
- Add "Create Listing" button
- Add link to create form
bugulma/frontend/services/discovery-api.ts
- Add createCommunityListing function
```
### Frontend - Integration
```
bugulma/frontend/pages/MapView.tsx
- Add community listing markers
bugulma/frontend/components/map/MapControls.tsx
- Add toggle for community listings
bugulma/frontend/components/ui/SearchBar.tsx
- Enhance to link to discovery page
```
---
## Quick Wins (Can Do Immediately)
### 1. Add Navigation Link (5 minutes)
Add to TopBar or Footer:
```typescript
<NavLink to="/discovery">Discover</NavLink>
```
### 2. Add Landing Page Button (10 minutes)
Add to Hero section:
```typescript
<Button onClick={() => navigate('/discovery')}>
Discover Resources
</Button>
```
### 3. Add Dashboard Quick Action (5 minutes)
Add to DashboardPage quick actions grid
### 4. Add "Create Listing" Button to DiscoveryPage (10 minutes)
Even if backend isn't ready, add button that shows "Coming Soon" or links to form
---
## Summary
**Current State**: Feature exists but is 100% hidden and 50% functional (can search, cannot create).
**Required Actions**:
1. ✅ Add navigation links (CRITICAL)
2. ✅ Implement creation handler (HIGH)
3. ✅ Create frontend form (HIGH)
4. ✅ Add map integration (MEDIUM)
5. ✅ Enhance user workflow (MEDIUM)
**Estimated Effort**:
- Quick wins (navigation links): 30 minutes
- Full implementation: 1-2 days
**Impact**: Making this feature discoverable and functional will enable the community resource sharing use case that was planned.
---
**Report Generated**: 2025-01-27

View File

@ -0,0 +1,854 @@
# Community Features Integration Strategy
## Comprehensive UX/UI Design for Multi-User Platform
**Date**: 2025-01-27
**Status**: Strategic Design Document
**Purpose**: Define how to integrate community features for different user types with optimal UX
---
## Executive Summary
This document provides a comprehensive integration strategy for community features that serves **multiple user personas** with **different use cases** and **varying technical sophistication**. The strategy focuses on:
1. **Progressive Disclosure**: Show features based on user type and context
2. **Unified Discovery**: Single search interface for all resource types
3. **Contextual Entry Points**: Different ways to access features based on user journey
4. **Role-Based UI**: Tailored interfaces for each user type
5. **Seamless Integration**: Community features feel native to the platform
---
## User Personas & Use Cases
### 1. **Citizen (Unauthenticated/Public User)**
**Profile**:
- Local resident, not a business owner
- Wants to find resources, share items, learn about sustainability
- May or may not have account
- Low technical sophistication
**Use Cases**:
- Browse community impact metrics
- Search for items/services they need
- View success stories
- Read community news
- Browse events calendar
- (If authenticated) Create community listings
**Key Needs**:
- Simple, intuitive interface
- No jargon or technical terms
- Clear value proposition
- Easy discovery of features
---
### 2. **Citizen (Authenticated Community Member)**
**Profile**:
- Registered user, not a business
- Active in community resource sharing
- May have listings or want to create them
**Use Cases**:
- Create community listings (products, services, tools, skills, needs)
- Manage their listings
- Search for resources
- Contact other users
- Track their impact
**Key Needs**:
- Easy listing creation flow
- Dashboard to manage listings
- Clear communication channels
- Visibility of their contributions
---
### 3. **Business User (Organization Owner/Manager)**
**Profile**:
- Owns or manages a business/organization
- Uses platform for B2B resource matching
- May also want to engage with community
**Use Cases**:
- Primary: B2B resource matching (existing)
- Secondary: Discover community resources
- View community impact (their business contribution)
- Engage with community (optional)
**Key Needs**:
- Don't overwhelm with community features
- Clear separation between B2B and community
- Option to participate in community (opt-in)
- See their business's community impact
---
### 4. **Content Manager**
**Profile**:
- Manages platform content
- Creates success stories, news, events
- Curates community content
**Use Cases**:
- Create/edit success stories
- Publish news articles
- Manage events calendar
- Moderate community listings (if needed)
**Key Needs**:
- Admin panel integration
- Content management tools
- Publishing workflow
- Analytics on content performance
---
### 5. **Admin**
**Profile**:
- Full platform access
- Manages all aspects including community features
**Use Cases**:
- All content manager use cases
- System configuration
- User management
- Analytics and reporting
**Key Needs**:
- Complete control
- Analytics dashboard
- Moderation tools
- Configuration options
---
### 6. **Viewer (Read-Only User)**
**Profile**:
- Limited access role
- Can view but not create
**Use Cases**:
- Browse all content
- Search resources
- View impact metrics
- Read news and stories
**Key Needs**:
- Clear read-only indicators
- No creation buttons visible
- Full browsing experience
---
## Integration Strategy: Multi-Entry Point Approach
### Entry Point 1: Landing Page (Public-Facing)
**Target Users**: Citizens (unauthenticated), first-time visitors
**Design**:
```
┌─────────────────────────────────────────────────┐
│ Hero Section │
│ - "Connect Your Business. Grow Together." │
│ - Primary CTA: "Explore Map" │
│ - Secondary CTA: "Discover Resources" (NEW) │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ Community Impact Section (NEW) │
│ - "See Our Impact" card │
│ - Key metrics: CO₂ saved, waste diverted │
│ - CTA: "View Impact Dashboard" │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ Resource Discovery Section (NEW) │
│ - "Find What You Need" │
│ - Search bar (links to /discovery) │
│ - Examples: "Tools", "Services", "Skills" │
│ - CTA: "Start Searching" │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ Success Stories Section (NEW) │
│ - Featured success story │
│ - CTA: "Read More Stories" │
└─────────────────────────────────────────────────┘
```
**Implementation**:
- Add "Discover Resources" button to Hero section
- Add new "Community Impact" section after Hero
- Add "Resource Discovery" section
- Add "Success Stories" preview section
- All link to respective community pages
---
### Entry Point 2: Main Navigation (TopBar)
**Target Users**: All authenticated users
**Design**:
```
┌─────────────────────────────────────────────────┐
│ [Logo] Map Discover Community Heritage │
│ ↓ │
│ ┌──────────────────┐ │
│ │ Impact │ │
│ │ Success Stories │ │
│ │ News │ │
│ │ Events │ │
│ │ Resources │ │
│ └──────────────────┘ │
└─────────────────────────────────────────────────┘
```
**Implementation**:
- Add "Discover" link (always visible)
- Add "Community" dropdown menu (for authenticated users)
- Dropdown shows: Impact, Stories, News, Events, Resources
---
### Entry Point 3: Dashboard (Role-Based)
**Target Users**: Authenticated users (role-specific)
#### For Business Users (user role)
```
┌─────────────────────────────────────────────────┐
│ Dashboard │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ My Orgs │ │ My Matches │ │
│ └─────────────┘ └─────────────┘ │
│ │
│ Quick Actions: │
│ [Create Resource Flow] [Find Matches] │
│ [Explore Map] [View Analytics] │
│ │
│ Community (Collapsible Section - NEW): │
│ ┌──────────────────────────────────────┐ │
│ │ 🌱 Community Resources │ │
│ │ Discover items from community members│ │
│ │ [Browse Community Resources] │ │
│ └──────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
```
#### For Citizens (user role, no organizations)
```
┌─────────────────────────────────────────────────┐
│ My Dashboard │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ My Listings │ │ My Requests │ │
│ └─────────────┘ └─────────────┘ │
│ │
│ Quick Actions: │
│ [Create Listing] [Search Resources] │
│ [View Impact] [Browse Events] │
│ │
│ Community Impact: │
│ [View Dashboard] │
└─────────────────────────────────────────────────┘
```
**Implementation**:
- Detect if user has organizations
- Show business-focused dashboard if yes
- Show community-focused dashboard if no
- Add collapsible "Community" section to business dashboard
- Full community dashboard for citizens
---
### Entry Point 4: Map Integration
**Target Users**: All users
**Design**:
```
┌─────────────────────────────────────────────────┐
│ Map View │
│ ┌─────────────────────────────────────────┐ │
│ │ [Organizations] [Products] [Community] │ │
│ └─────────────────────────────────────────┘ │
│ │
│ Map Layers: │
│ ☑ Organizations (default) │
│ ☐ Business Products/Services │
│ ☐ Community Listings (NEW) │
│ │
│ When Community layer enabled: │
│ - Show markers for community listings │
│ - Different icon/style than business markers │
│ - Click marker → Show listing preview │
│ - Link to full listing or discovery page │
└─────────────────────────────────────────────────┘
```
**Implementation**:
- Add "Community" toggle in MapControls
- Fetch community listings for visible bounds
- Display markers with distinct styling
- Show popup with listing details
- Link to discovery page or detail view
---
### Entry Point 5: Universal Search
**Target Users**: All users
**Design**:
```
┌─────────────────────────────────────────────────┐
│ Search Bar (TopBar/Landing) │
│ ┌─────────────────────────────────────────┐ │
│ │ 🔍 Search for resources, services... │ │
│ └─────────────────────────────────────────┘ │
│ │
│ Search Results Page (/discovery): │
│ ┌─────────────────────────────────────────┐ │
│ │ Tabs: [All] [Business] [Community] │ │
│ │ │ │
│ │ Results: │ │
│ │ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ │ Bus │ │ Comm │ │ Bus │ │
│ │ │ Prod │ │ Item │ │ Serv │ │
│ │ └──────┘ └──────┘ └──────┘ │
│ │ │ │
│ │ Filters: Category, Location, Price │ │
│ └─────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
```
**Implementation**:
- Enhance SearchBar to link to /discovery
- DiscoveryPage already exists (needs enhancement)
- Add clear visual distinction between business and community results
- Add filters for listing type
---
## UI/UX Design Patterns by User Type
### Pattern 1: Progressive Disclosure for Business Users
**Principle**: Don't overwhelm business users with community features
**Implementation**:
- **Default View**: Business dashboard shows B2B features prominently
- **Community Section**: Collapsible/expandable section at bottom
- **Opt-In**: "Explore Community Resources" button (not forced)
- **Contextual**: Show community features when relevant (e.g., "Similar items available from community")
**Example Flow**:
```
Business User Dashboard
→ Primary: B2B matching features
→ Secondary: "🌱 Community Resources" (collapsed)
→ Click to expand
→ Shows: "Discover community resources"
→ CTA: "Browse Community Listings"
```
---
### Pattern 2: Citizen-First Design for Community Members
**Principle**: Make community features primary for citizens
**Implementation**:
- **Default View**: Community dashboard if user has no organizations
- **Primary Actions**: Create listing, search resources, view impact
- **Simplified Language**: Avoid business jargon
- **Visual Hierarchy**: Community features prominent
**Example Flow**:
```
Citizen Dashboard
→ Hero: "Share Resources, Build Community"
→ Quick Actions: [Create Listing] [Search] [View Impact]
→ My Listings: Active listings
→ Community Feed: Recent activity
```
---
### Pattern 3: Unified Discovery Interface
**Principle**: Single search, multiple resource types
**Implementation**:
- **One Search Bar**: Works for all resource types
- **Unified Results**: Business + Community in one view
- **Clear Labels**: "Business Product" vs "Community Listing"
- **Filtering**: Easy to filter by source (business/community)
**Visual Design**:
```
Search Result Card:
┌─────────────────────────────────────┐
│ [Business Badge] Product Name │
│ From: Organization Name │
│ Location: 2.3 km away │
│ Price: €50 │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ [Community Badge] Item Name │
│ From: Community Member │
│ Location: 1.5 km away │
│ Price: Free / €20 │
└─────────────────────────────────────┘
```
---
### Pattern 4: Contextual Creation Flows
**Principle**: Show creation options when relevant
**Implementation**:
- **Discovery Page**: "Create Listing" button (always visible if authenticated)
- **Empty States**: "No listings found. Be the first!" with CTA
- **Map View**: "Add your listing here" when viewing area
- **Dashboard**: Prominent "Create" button
**Creation Flow Options**:
1. **Quick Create**: Simple form for common items
2. **Full Create**: Complete form with all fields
3. **Wizard**: Step-by-step for complex listings
---
### Pattern 5: Role-Based Feature Visibility
**Principle**: Show features based on user role and permissions
**Implementation Matrix**:
| Feature | Public | Citizen | Business | Content Mgr | Admin |
|---------|--------|--------|----------|-------------|-------|
| Browse Listings | ✅ | ✅ | ✅ | ✅ | ✅ |
| Search Resources | ✅ | ✅ | ✅ | ✅ | ✅ |
| View Impact | ✅ | ✅ | ✅ | ✅ | ✅ |
| View Stories | ✅ | ✅ | ✅ | ✅ | ✅ |
| View News | ✅ | ✅ | ✅ | ✅ | ✅ |
| View Events | ✅ | ✅ | ✅ | ✅ | ✅ |
| Create Listing | ❌ | ✅ | ✅ | ✅ | ✅ |
| Edit Own Listing | ❌ | ✅ | ✅ | ✅ | ✅ |
| Delete Own Listing | ❌ | ✅ | ✅ | ✅ | ✅ |
| Create Story | ❌ | ❌ | ❌ | ✅ | ✅ |
| Create News | ❌ | ❌ | ❌ | ✅ | ✅ |
| Create Event | ❌ | ❌ | ❌ | ✅ | ✅ |
| Moderate Listings | ❌ | ❌ | ❌ | ✅ | ✅ |
---
## Component Architecture
### New Components Needed
#### 1. CommunityNavigationMenu
**Purpose**: Dropdown menu for community features
**Location**: `components/community/CommunityNavigationMenu.tsx`
**Props**:
```typescript
interface CommunityNavigationMenuProps {
userRole?: UserRole;
isAuthenticated: boolean;
}
```
#### 2. CommunityImpactCard
**Purpose**: Display impact metrics on landing page
**Location**: `components/community/CommunityImpactCard.tsx`
**Props**:
```typescript
interface CommunityImpactCardProps {
metrics: {
co2Saved: number;
wasteDiverted: number;
connections: number;
};
compact?: boolean; // For landing page vs full page
}
```
#### 3. ResourceTypeBadge
**Purpose**: Visual indicator for business vs community listings
**Location**: `components/discovery/ResourceTypeBadge.tsx`
**Props**:
```typescript
interface ResourceTypeBadgeProps {
type: 'business' | 'community';
listingType?: 'product' | 'service' | 'tool' | 'skill' | 'need';
}
```
#### 4. CreateListingButton
**Purpose**: Contextual button to create listings
**Location**: `components/community/CreateListingButton.tsx`
**Props**:
```typescript
interface CreateListingButtonProps {
variant?: 'primary' | 'outline' | 'ghost';
size?: 'sm' | 'md' | 'lg';
location?: 'dashboard' | 'discovery' | 'map';
}
```
#### 5. CommunityDashboard
**Purpose**: Dashboard for citizens (no organizations)
**Location**: `pages/CommunityDashboard.tsx`
**Features**:
- My Listings section
- Quick actions
- Community impact summary
- Recent activity feed
---
## Integration Points
### 1. Landing Page Integration
**File**: `pages/LandingPage.tsx`
**Changes**:
```typescript
// Add new sections
<Hero
onNavigateToMap={...}
onNavigateToDiscovery={() => navigate('/discovery')} // NEW
onAddOrganizationClick={...}
/>
<CommunityImpactSection /> // NEW
<ResourceDiscoverySection /> // NEW
<SuccessStoriesPreview /> // NEW
```
**New Components**:
- `components/landing/CommunityImpactSection.tsx`
- `components/landing/ResourceDiscoverySection.tsx`
- `components/landing/SuccessStoriesPreview.tsx`
---
### 2. Navigation Integration
**File**: `components/layout/TopBar.tsx` or `HeaderActions.tsx`
**Changes**:
```typescript
// Add to navigation
<NavLink to="/discovery">Discover</NavLink>
// Add dropdown for authenticated users
{isAuthenticated && (
<CommunityNavigationMenu userRole={user?.role} />
)}
```
---
### 3. Dashboard Integration
**File**: `pages/DashboardPage.tsx`
**Changes**:
```typescript
// Detect user type
const hasOrganizations = userOrganizations?.length > 0;
// Show different dashboard based on user type
{hasOrganizations ? (
<BusinessDashboard
communitySection={<CommunityResourcesSection />} // Collapsible
/>
) : (
<CommunityDashboard /> // Full community dashboard
)}
```
---
### 4. Map Integration
**File**: `pages/MapView.tsx`
**Changes**:
```typescript
// Add community layer toggle
const [showCommunityListings, setShowCommunityListings] = useState(false);
// Fetch community listings when layer enabled
const { data: communityListings } = useCommunityListings({
enabled: showCommunityListings,
bounds: mapBounds
});
// Render markers
{showCommunityListings && communityListings?.map(listing => (
<CommunityListingMarker key={listing.id} listing={listing} />
))}
```
**New Component**: `components/map/CommunityListingMarker.tsx`
---
### 5. Discovery Page Enhancement
**File**: `pages/DiscoveryPage.tsx`
**Enhancements**:
- Add "Create Listing" button (if authenticated)
- Improve visual distinction between business/community
- Add filters for listing source
- Add empty states with CTAs
- Add "My Listings" link for authenticated users
---
## User Flows
### Flow 1: Citizen Discovers Feature (First Time)
```
Landing Page
→ Sees "Discover Resources" button
→ Clicks → Discovery Page
→ Sees search interface
→ Searches for "tools"
→ Sees results (business + community)
→ Clicks community listing
→ Views details
→ (If not authenticated) Prompted to sign up
→ Signs up
→ Can now create listings
```
### Flow 2: Citizen Creates Listing
```
Discovery Page (authenticated)
→ Clicks "Create Listing" button
→ Selects listing type (product/service/tool/skill/need)
→ Fills form:
- Title, description
- Category
- Location (map picker)
- Price (if applicable)
- Images
→ Submits
→ Redirected to "My Listings" dashboard
→ Sees their new listing
```
### Flow 3: Business User Explores Community (Optional)
```
Business Dashboard
→ Sees collapsed "Community Resources" section
→ Expands section
→ Clicks "Browse Community Listings"
→ Discovery Page (filtered to community)
→ Finds relevant community resource
→ Contacts community member
→ (Optional) Creates their own community listing
```
### Flow 4: Public User Views Impact
```
Landing Page
→ Sees "Community Impact" section
→ Clicks "View Impact Dashboard"
→ Community Impact Page
→ Sees metrics, map, stories
→ Clicks "Success Stories"
→ Reads stories
→ Inspired to participate
→ Signs up
```
---
## Accessibility & Usability Considerations
### 1. Language & Terminology
**For Citizens**:
- ✅ "Share Resources" (not "List Products")
- ✅ "Find What You Need" (not "Discovery")
- ✅ "Community Member" (not "User")
- ✅ "Free" (not "No Cost")
**For Business Users**:
- ✅ "Business Resources" vs "Community Resources" (clear distinction)
- ✅ "B2B Matching" vs "Community Exchange" (separate concepts)
### 2. Visual Hierarchy
**Business Dashboard**:
- Primary: B2B features (large, prominent)
- Secondary: Community features (smaller, collapsible)
**Community Dashboard**:
- Primary: Community features (large, prominent)
- Secondary: Business features (if applicable, smaller)
### 3. Progressive Enhancement
**Level 1 (Public)**:
- Browse listings
- View impact
- Read stories/news
**Level 2 (Authenticated)**:
- Create listings
- Manage listings
- Contact others
**Level 3 (Content Manager)**:
- Create content
- Moderate listings
**Level 4 (Admin)**:
- Full control
- Analytics
- Configuration
### 4. Mobile Responsiveness
- **Discovery Page**: Card-based layout, swipeable
- **Creation Form**: Step-by-step wizard on mobile
- **Map**: Full-screen with bottom sheet for details
- **Dashboard**: Stack layout on mobile
---
## Implementation Phases
### Phase 1: Discovery & Navigation (Week 1)
**Goal**: Make features discoverable
1. Add navigation links (TopBar, Footer)
2. Add landing page sections
3. Enhance DiscoveryPage with creation button
4. Add role-based visibility
**Deliverables**:
- Navigation menu updated
- Landing page sections added
- Discovery page enhanced
---
### Phase 2: Creation & Management (Week 2)
**Goal**: Enable listing creation
1. Implement backend creation handler
2. Create frontend form component
3. Add "My Listings" dashboard section
4. Add edit/delete functionality
**Deliverables**:
- Backend API working
- Creation form functional
- Management interface
---
### Phase 3: Map Integration (Week 3)
**Goal**: Show listings on map
1. Add community layer toggle
2. Fetch listings for map bounds
3. Display markers
4. Show popup/details
**Deliverables**:
- Map shows community listings
- Markers styled differently
- Click to view details
---
### Phase 4: Dashboard Differentiation (Week 4)
**Goal**: Role-based dashboards
1. Detect user type (has organizations?)
2. Create CommunityDashboard component
3. Add collapsible section to BusinessDashboard
4. Test different user flows
**Deliverables**:
- Different dashboards for different users
- Smooth user experience
---
### Phase 5: Content Features (Week 5-6)
**Goal**: Impact, Stories, News, Events
1. Implement Impact Dashboard
2. Create Success Stories page
3. Create News Feed page
4. Create Events Calendar
**Deliverables**:
- All community content pages
- Admin content management
---
## Success Metrics
### Discovery Metrics
- % of users who visit Discovery page
- % of users who create listings
- Time to first listing creation
### Engagement Metrics
- Number of community listings created
- Number of searches including community results
- Map layer usage (community vs business)
### User Satisfaction
- User feedback on discoverability
- Ease of use ratings
- Feature adoption rates
---
## Conclusion
This integration strategy provides:
1. **Multiple Entry Points**: Users can discover features from landing page, navigation, dashboard, map, or search
2. **Role-Based Experience**: Each user type sees relevant features prominently
3. **Progressive Disclosure**: Features revealed based on context and need
4. **Unified Discovery**: Single search interface for all resource types
5. **Clear Visual Distinction**: Business vs Community resources clearly labeled
6. **Accessible Design**: Language and UI appropriate for each user type
**Next Steps**:
1. Review and approve strategy
2. Create detailed mockups for each component
3. Begin Phase 1 implementation
4. Gather user feedback and iterate
---
**Document Version**: 1.0
**Last Updated**: 2025-01-27
**Status**: Ready for Review & Implementation

View File

@ -1,339 +0,0 @@
# EU Funding & Tenders Portal API Clients
This repository contains Python and Go clients for accessing the EU Funding & Tenders Portal APIs. These clients allow you to programmatically search for funding opportunities, track grant updates, and access various EU funding-related data.
## Features
- **Grants & Tenders Search**: Find calls for proposals and tenders
- **Topic Details**: Get detailed information about specific funding topics
- **Grant Updates**: Monitor funding opportunity updates
- **FAQ Search**: Access frequently asked questions
- **Organization Data**: Retrieve public organization information
- **Partner Search**: Find potential partners for consortia
- **Project Results**: Browse EU-funded projects
## API Endpoints
The clients access the following EU Funding & Tenders Portal APIs:
- **Search API**: `https://api.tech.ec.europa.eu/search-api/prod/rest/search`
- **Facet API**: `https://api.tech.ec.europa.eu/search-api/prod/rest/facet`
- **Document API**: `https://api.tech.ec.europa.eu/search-api/prod/rest/document`
## Python Client (`eu_funding_api.py`)
### Requirements
```bash
pip install requests
```
### Usage
#### Basic Usage
```python
from eu_funding_api import EUFundingAPI
# Initialize the API client
api = EUFundingAPI()
# Search for all grants and tenders
results = api.search_grants_tenders()
processed_results = results.get('processed_results', [])
print(f"Found {len(processed_results)} opportunities")
# Access processed data (metadata fields extracted)
for opportunity in processed_results[:3]:
print(f"- {opportunity['title']} (Status: {opportunity['status']})")
```
#### Search for EIC Accelerator Opportunities
```python
# Search specifically for EIC Accelerator
eic_query = {
"bool": {
"must": [
{
"terms": {
"type": ["1", "2", "8"] # Grants
}
},
{
"terms": {
"status": ["31094501", "31094502", "31094503"] # All statuses
}
},
{
"term": {
"callIdentifier": "HORIZON-EIC-2026-ACCELERATOR-01"
}
}
]
}
}
results = api.search_grants_tenders(eic_query)
processed_results = results.get('processed_results', [])
for opportunity in processed_results:
print(f"Title: {opportunity['title']}")
print(f"Identifier: {opportunity['identifier']}")
print(f"Status: {opportunity['status']}")
print(f"Deadline: {opportunity['deadline']}")
print("---")
```
#### Get Topic Details
```python
# Get detailed information about a specific topic
topic_details = api.get_topic_details("HORIZON-EIC-2026-ACCELERATOR-01")
processed_topics = topic_details.get('processed_results', [])
if processed_topics:
topic = processed_topics[0]
print(f"Title: {topic['title']}")
print(f"Status: {topic['status']}")
print(f"Call Identifier: {topic['callIdentifier']}")
print(f"Deadline: {topic['deadline']}")
print(f"Description: {topic['description']}")
```
#### Monitor EIC Accelerator (Command Line)
```bash
python eu_funding_api.py monitor
```
### Data Structure
The EU Funding APIs return data in a specific structure:
- **Raw Results**: Available in `results` field (contains nested metadata)
- **Processed Results**: Available in `processed_results` field (metadata fields extracted)
#### Processed Result Fields
Each processed result contains:
- `title`: The opportunity title
- `identifier`: Unique identifier
- `callIdentifier`: Call/topic identifier
- `status`: Status code (see reference codes)
- `deadline`: Application deadline
- `description`: Detailed description
- `type`: Opportunity type
- `frameworkProgramme`: Framework programme code
- `raw_data`: Original API response for debugging
#### Example Processed Result
```python
{
"title": "EIC Accelerator",
"identifier": "HORIZON-EIC-2026-ACCELERATOR-01",
"callIdentifier": "HORIZON-EIC-2026-ACCELERATOR-01",
"status": "31094501",
"deadline": "2026-03-15",
"description": "Support for innovative SMEs...",
"type": "1",
"frameworkProgramme": "43108390",
"raw_data": {...} # Original API response
}
```
## Go Client (`eu_funding_api.go`)
### Requirements
```bash
go mod init eu-funding-api
go mod tidy
```
### Usage
#### Basic Usage
```go
package main
import (
"fmt"
"log"
)
func main() {
api := NewEUFundingAPI()
// Search for grants and tenders
results, err := api.SearchGrantsTenders(nil)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Found %d opportunities\n", len(results.Results))
}
```
#### Search for EIC Accelerator
```go
eicQuery := &SearchQuery{}
eicQuery.Bool.Must = []interface{}{
map[string]interface{}{
"terms": map[string][]string{
"type": {"1", "2", "8"},
},
},
map[string]interface{}{
"terms": map[string][]string{
"status": {"31094501", "31094502", "31094503"},
},
},
map[string]interface{}{
"term": map[string]string{
"callIdentifier": "HORIZON-EIC-2026-ACCELERATOR-01",
},
},
}
results, err := api.SearchGrantsTenders(eicQuery)
```
#### Get Topic Details
```go
topicDetails, err := api.GetTopicDetails("HORIZON-EIC-2026-ACCELERATOR-01")
```
### Available Methods
- `SearchGrantsTenders(query *SearchQuery)` - Search for grants and tenders
- `GetTopicDetails(topicIdentifier string)` - Get topic details
- `SearchGrantUpdates(frameworkProgramme string)` - Search grant updates
- `SearchFAQs(programme string)` - Search FAQs
- `GetOrganizationData(picCode string)` - Get organization data
- `SearchPartners(topic string)` - Search for partners
- `SearchProjects(programmeID, missionGroup string)` - Search projects
## API Keys and Authentication
The APIs use the following keys:
- **Search API**: `SEDIA`
- **FAQ API**: `SEDIA_FAQ`
- **Person/Organization API**: `SEDIA_PERSON`
No additional authentication is required for public data access.
## Query Examples
### Search for Open Grants
```json
{
"bool": {
"must": [
{
"terms": {
"type": ["1", "2", "8"]
}
},
{
"terms": {
"status": ["31094501"]
}
}
]
}
}
```
### Search by Framework Programme (Horizon Europe)
```json
{
"bool": {
"must": [
{
"terms": {
"frameworkProgramme": ["43108390"]
}
}
]
}
}
```
### Search by Topic
```json
{
"bool": {
"must": [
{
"term": {
"callIdentifier": "HORIZON-EIC-2026-ACCELERATOR-01"
}
}
]
}
}
```
## Reference Data Codes
Use the Facet API to understand reference codes:
- **Status Codes**:
- `31094501`: Open
- `31094502`: Forthcoming
- `31094503`: Closed
- **Type Codes**:
- `0`: Call for tenders
- `1`: Call for proposals
- `2`: Prize
- `8`: Cascade funding
- **Framework Programmes**:
- `43108390`: Horizon Europe
## Integration with City Resource Graph
These API clients are designed to support the City Resource Graph project by:
1. **Automated Monitoring**: Track new funding opportunities
2. **Data Collection**: Gather comprehensive funding data
3. **Application Preparation**: Access detailed topic information
4. **Partner Finding**: Discover potential consortium partners
5. **Project Research**: Study successful EU-funded projects
## Error Handling
Both clients include proper error handling:
- Network timeouts (30 seconds default)
- HTTP status code validation
- JSON parsing error handling
- User-friendly error messages
## Rate Limiting
The EU APIs may have rate limits. Consider implementing:
- Request throttling
- Caching mechanisms
- Retry logic with exponential backoff
## License
This code is provided for the City Resource Graph project and EU funding application purposes.
## Support
For API-related issues, refer to the [EU Funding & Tenders Portal API documentation](https://ec.europa.eu/info/funding-tenders/opportunities/portal/screen/support/apis).</content>
<parameter name="filePath">/Users/damirmukimov/city_resource_graph/EU_FUNDING_API_README.md

View File

@ -1,263 +0,0 @@
# Implementation Gap Report: Mathematical Model vs. Concept & Schemas
**Date:** November 1, 2025
**Status:** Comprehensive review completed
## Executive Summary
The current mathematical model provides excellent **overall business economics** but is missing critical **individual match economics** and **matching engine** components required by the platform concept and schemas. While we have comprehensive exchange cost calculations, we're missing the core platform functionality for matching businesses and calculating match-specific economics.
## Current Implementation Status
### ✅ **COMPLETED COMPONENTS**
#### 1. Exchange Cost Calculator (Transport Module)
- **Status:** ✅ FULLY IMPLEMENTED
- **Coverage:** All 16 symbiosis types from concept
- **Features:**
- Capital/operating costs for all exchange types
- Complexity multipliers (1.0x/1.3x/1.8x)
- Risk mitigation costs (1-5% based on risk level)
- Regulatory compliance costs (0.05-3% by type)
- Feasibility scoring
- CLI integration (`models exchange`)
- Comprehensive test coverage
#### 2. Unit Economics Model
- **Status:** ✅ FULLY IMPLEMENTED
- **Coverage:** LTV/CAC calculations with tier-specific analysis
- **Features:**
- Tier-specific LTV calculations (Basic/Business/Enterprise)
- Churn rate modeling
- Upsell revenue calculations
- Payback period analysis
- Blended LTV/CAC ratios
#### 3. Overall Business Profitability
- **Status:** ✅ FULLY IMPLEMENTED
- **Coverage:** NPV/IRR/Payback for entire business model
- **Features:**
- 10-year NPV calculations
- IRR computation with Newton's method
- Payback period analysis
- Discount rate sensitivity
#### 4. Business Model Components
- **Status:** ✅ FULLY IMPLEMENTED
- **Coverage:** Customer growth, revenue, costs, environmental impact
- **Features:**
- Multi-tier subscription revenue
- Transaction/marketplace revenue
- Municipal revenue streams
- Comprehensive cost structure
- CO2 reduction calculations
- Validation rules and sanity checks
---
## ✅ **RECENTLY COMPLETED COMPONENTS**
### 1. **INDIVIDUAL MATCH ECONOMICS** - ✅ IMPLEMENTED
**Status:** ✅ FULLY IMPLEMENTED (November 1, 2025)
**Source:** `concept/schemas/economic_calculation.json`
**Location:** `models/match/` (300+ lines, 13 test cases)
**CLI:** `models match --source-id X --target-id Y --annual-qty N --unit-value V`
**Implemented Calculations:**
- ✅ **Annual savings** from individual matches (€)
- ✅ **Match-specific NPV/IRR/Payback** (10-year horizons with Newton's method)
- ✅ **Transportation costs** per match (integrated with exchange cost calculator)
- ✅ **CO2 reduction per match** (tonnes/year with configurable factors)
- ✅ **Implementation complexity** assessment (low/medium/high)
- ✅ **Regulatory requirements** tracking (waste permits, energy licenses, insurance)
**Example Output:**
```
Individual Match Economic Analysis
==================================
Match ID: match_waste_heat_001_process_heat_001
Economic Results:
Annual Savings: €560,000
Payback Period: 0.0 years
NPV (10 years): €3,831,287
IRR: 2127.8%
Transportation & Impact:
CO2 Reduction: 4.0 tonnes/year
Regulatory Requirements: [energy_distribution_license liability_insurance]
✅ Match appears economically viable
```
## ✅ **RECENTLY COMPLETED COMPONENTS**
### 2. **GEOSPATIAL CALCULATIONS PACKAGE** - ✅ IMPLEMENTED
**Status:** ✅ FULLY IMPLEMENTED (January 2025)
**Location:** `bugulma/backend/internal/geospatial/` (11 files, 30+ test cases)
**Implemented Features:**
- ✅ **Haversine Distance**: Accurate great-circle distance calculations
- ✅ **Vincenty Distance**: Ellipsoidal distance calculations for high accuracy
- ✅ **Bearing Calculations**: Direction, midpoint, and destination point calculations
- ✅ **Bounding Box Operations**: Calculate, expand, area, point-in-box checks
- ✅ **Route Calculations**: Multi-point routes with segment details and time estimates
- ✅ **Distance Matrix**: Efficient all-pairs distance calculations
- ✅ **PostGIS Integration**: Query generation helpers for database spatial operations
- ✅ **Coordinate Transformations**: WGS84 ↔ Web Mercator conversions
- ✅ **Spatial Validation**: Comprehensive point and bounding box validation
- ✅ **Matching Service Integration**: Replaced placeholder distance calculations
**Impact**: Fixed critical bug where all distances were hardcoded to 10km. Matching service now uses accurate geographic calculations.
## ❌ **REMAINING MISSING COMPONENTS**
- **Maintenance cost factors** (5% of capital annually)
- **Energy cost inflation** modeling (2% annually)
**Data Structures Missing:**
```json
{
"match_id": "uuid",
"source_resource": "resource_flow",
"target_resource": "resource_flow",
"calculations": {
"annual_savings": 50000,
"payback_period_years": 2.1,
"npv_10_years": 150000,
"irr_percent": 25.0,
"transportation_costs": {
"annual_cost": 8400,
"distance_km": 2.0,
"method": "pipeline"
},
"co2_reduction_tonnes": 500,
"implementation_complexity": "medium",
"regulatory_requirements": ["waste_permit", "transport_license"]
}
}
```
### 2. **MATCHING ENGINE ALGORITHMS** - HIGH PRIORITY
**Source:** `concept/10_matching_engine_core_algorithm.md`
**Impact:** Platform cannot match businesses
**Missing Algorithms:**
- **Multi-stage matching pipeline:**
- Pre-filtering (resource type, geography, quality, regulatory)
- Compatibility assessment with weighted scoring
- Economic viability analysis per match
- **Compatibility scoring:**
```
score = w1*quality_compatibility + w2*temporal_overlap + w3*quantity_match
+ w4*trust_factors - w5*transport_cost_penalty - w6*regulatory_risk
```
- **Advanced optimization:**
- Max-flow/min-cost algorithms
- Clustering for symbiosis zones
- Multi-criteria decision support (AHP, fuzzy logic)
### 3. **MATCH LIFECYCLE MANAGEMENT** - HIGH PRIORITY
**Source:** `concept/schemas/match.json`
**Impact:** No match state management
**Missing Features:**
- **Match states:** suggested → negotiating → reserved → contracted → live → failed/cancelled
- **Negotiation history** tracking
- **Contract details** management
- **Economic value** per match
- **Risk assessments** (technical/regulatory/market)
- **Transportation estimates** per match
- **Priority scoring** (1-10 scale)
### 4. **RESOURCE FLOW COMPATIBILITY** - MEDIUM PRIORITY
**Source:** `concept/schemas/resource_flow.json`
**Impact:** Cannot validate resource matches
**Missing Components:**
- **Quality compatibility** assessment (temperature, pressure, purity, grade)
- **Temporal overlap** analysis (availability schedules, seasonality)
- **Quantity matching** algorithms
- **Economic data** integration (cost_in, cost_out, waste_disposal_cost)
- **Constraint validation** (max_distance, permits, quality thresholds)
- **Service domain** matching (maintenance, consulting, transport)
### 5. **DATA QUALITY & TRUST METRICS** - MEDIUM PRIORITY
**Source:** Concept documents (data quality death spiral prevention)
**Impact:** No quality differentiation between businesses
**Missing Features:**
- **Profile completeness** scoring
- **Data source validation** (declared/device/calculated)
- **Device signature** verification
- **Precision level** assessment (rough/estimated/measured)
- **Trust factor** calculations
- **Historical transaction** success rates
### 6. **REGULATORY COMPLIANCE TRACKING** - MEDIUM PRIORITY
**Source:** Multiple schemas (resource_flow, match, economic_calculation)
**Impact:** Cannot assess regulatory feasibility
**Missing Features:**
- **Permit requirement** identification
- **Regulatory risk** assessment
- **Compliance status** tracking
- **Approval timeline** estimates
- **Cross-border** regulatory considerations
---
## 🔧 **IMPLEMENTATION ROADMAP**
### Phase 1: Core Matching Infrastructure (Week 1-2)
1. **Match Data Structures** - Implement match.json schema structures
2. **Resource Flow Models** - Basic resource flow compatibility
3. **Simple Compatibility Scoring** - Basic matching algorithm
### Phase 2: Economic Match Calculations (Week 3-4)
1. **Individual Match Economics** - NPV/IRR/payback per match
2. **Transportation Cost Integration** - Link exchange costs to matches
3. **CO2 Impact per Match** - Match-specific environmental calculations
### Phase 3: Advanced Matching Engine (Week 5-6)
1. **Multi-Criteria Decision Support** - AHP, fuzzy logic integration
2. **Optimization Algorithms** - Max-flow, clustering
3. **Regulatory Compliance** - Permit and approval tracking
### Phase 4: Data Quality & Trust (Week 7-8)
1. **Profile Completeness Scoring**
2. **Trust Factor Calculations**
3. **Historical Performance Tracking**
---
## 📊 **IMPACT ASSESSMENT**
| Component | Current Status | Business Impact | Implementation Effort |
|-----------|----------------|----------------|----------------------|
| Exchange Cost Calculator | ✅ Complete | Medium | ✅ Done |
| Individual Match Economics | ✅ Complete | **HIGH** | ✅ Done |
| Advanced Economic Calculator | ✅ Complete | **HIGH** | ✅ Done |
| Geospatial Calculations | ✅ Complete | **HIGH** | ✅ Done |
| Matching Engine | ✅ Partial | **CRITICAL** | High |
| Match Lifecycle | ✅ Complete | **HIGH** | ✅ Done |
| Resource Compatibility | ✅ Partial | **HIGH** | Medium |
| Data Quality | ❌ Missing | Medium | Low |
**Key Progress:**
- ✅ Individual match economics implemented - platform can calculate economic viability of specific business-to-business matches
- ✅ Advanced economic calculator extensions completed - comprehensive financial analysis with sensitivity analysis, risk assessment, and CO2 breakdown
- ✅ Geospatial calculations package implemented - fixed critical bug where distances were hardcoded to 10km
- ✅ Matching engine core algorithms implemented with geographic filtering
**Key Finding:** The platform now has accurate geographic calculations, comprehensive economic analysis capabilities, and can match businesses based on real distances with full financial viability assessment. Next critical gaps: advanced optimization algorithms and data quality metrics.
---
## 🎯 **NEXT STEPS**
1. ✅ **COMPLETED:** Individual match economics implemented
2. **Immediate Priority:** Implement matching engine algorithms (compatibility scoring)
3. **Architecture Decision:** Create `matching` package for core algorithms
4. **Integration Point:** Link match economics to compatibility scoring
5. **Testing Strategy:** Integration tests for end-to-end matching scenarios
---
*This report identifies the critical gaps between the implemented mathematical model and the platform requirements specified in the concept and schemas.*

View File

@ -0,0 +1,327 @@
# Most Original & Useful Features for Small City Platform
## The Core Insight
Small cities have **information gaps** and **coordination problems** that big platforms don't solve. This platform can become the **"Operating System for Small City Life"** - the one place people go to find anything, connect with anyone, and solve any local problem.
---
## Top 5 Most Original Features
### 1. "I Need X, Who Has It?" - Universal Resource Discovery ⭐⭐⭐⭐⭐
**The Problem**: In small cities, you don't know who has what. Need a ladder? A truck? A plumber? Information is fragmented.
**The Solution**: Real-time resource discovery that combines:
- Business resources (from existing platform)
- Community items (tools, equipment, services)
- Skills and services (people offering help)
- Needs (people looking for help)
**How It Works**:
```
User searches: "I need a van to move furniture"
Platform shows:
- Businesses with vans (rental)
- Community members with vans (borrow/share)
- Delivery services
- People going that direction (ride share)
All on one map, ranked by distance and availability.
```
**Why It's Original**:
- Not just a directory - it's **real-time matching**
- Combines B2B and community in one search
- Location-based (critical for small cities)
- Leverages existing business data
**Implementation**:
- Extend existing resource matching to community
- Add "community listings" table
- Unified search across business + community resources
- Real-time availability status
---
### 2. "Before You Buy Online, Check Local First" - Local Economy Defender ⭐⭐⭐⭐⭐
**The Problem**: Money leaves small cities. People buy online because they don't know local alternatives exist.
**The Solution**: Proactive local business promotion that intercepts online shopping:
**Features**:
- **Search Interception**: User searches for product → Platform suggests local alternatives first
- **Price Comparison**: Shows local price vs. online (including delivery/time costs)
- **"Local Alternative" Badge**: Businesses on platform get visibility boost
- **Community Currency**: Earn points for shopping local, redeemable at any platform business
**Example Flow**:
```
User: "I need office chairs"
Platform:
"Before Amazon, check these local options:
- Furniture Store A (2km, €50, available today)
- Business B has surplus chairs (free, 1km)
- Local carpenter can make custom (€60, 3 days)
Earn 10 points for shopping local!"
```
**Why It's Original**:
- **Proactive** (not passive directory)
- **Network effects**: More businesses = better alternatives
- **Gamification**: Points system keeps money local
- **Economic impact tracking**: "We kept €X in Bugulma this month"
**Implementation**:
- Business product/service catalog
- Search algorithm that prioritizes local
- Points/rewards system
- Integration with existing business directory
---
### 3. "Skill Exchange Network" - Connect People with Skills ⭐⭐⭐⭐⭐
**The Problem**: In small cities, fewer specialists. Hard to find: plumber who speaks Tatar, IT person, accountant, tutor.
**The Solution**: Skill marketplace that matches people with skills to people who need them:
**Features**:
- **Skill Directory**: People list skills (paid or free)
- **Smart Matching**: "I need X" → Finds people with skill X nearby
- **Trust System**: Platform businesses verified, community ratings
- **Skill Sharing**: "I'll teach you Y if you teach me Z"
**Example**:
```
User: "I need someone to fix my computer"
Platform shows:
- Business A (IT services, verified, 1km, €30/hr)
- Community member B (computer repair, 2km, €20/hr, 5 stars)
- Community member C (free help, 3km, available weekends)
```
**Why It's Original**:
- Uses platform's **matching technology for people**
- Builds on existing **business trust network**
- Creates **economic opportunities** for residents
- Solves real **small-city problem** (fewer specialists)
**Implementation**:
- Skills/services table
- User profiles with skills
- Matching algorithm (location + skill + availability)
- Rating/review system
---
### 4. "Community Transportation Network" - Shared Mobility ⭐⭐⭐⭐
**The Problem**: Limited public transport. Need rides but don't know who's going. Businesses need deliveries.
**The Solution**: Transportation coordination that matches:
- People needing rides ↔ People going that direction
- Businesses needing deliveries ↔ Community members with vehicles
- Regular commutes ↔ Carpool groups
**Features**:
- **Ride Sharing**: "I'm going to [place] at [time], have 3 seats"
- **Delivery Network**: Businesses post delivery needs, community members fulfill
- **Route Matching**: Automatic matching of similar routes
- **Cost Sharing**: Automatic cost calculation
**Example**:
```
Business: "Need delivery from Warehouse to Shop (5km), pay €15"
Community member: "I'm going that direction anyway, I'll do it"
OR
User: "Need ride to airport, Friday 8am"
Platform matches: "3 people going that direction, share cost €5 each"
```
**Why It's Original**:
- Combines **business needs** (deliveries) with **community** (rides)
- **Route-based matching** (not just location)
- **Economic model**: People earn from sharing
- Solves real **small-city problem**
**Implementation**:
- Ride offers/requests tables
- Route matching algorithm
- Delivery coordination system
- Payment integration (future)
---
### 5. "Everything Happening in Bugulma" - Centralized Information Hub ⭐⭐⭐⭐
**The Problem**: Information is fragmented. Events on Facebook, business news on websites, announcements on flyers.
**The Solution**: Single source of truth for everything happening:
**Features**:
- **Unified Feed**: All events, business updates, community news in one place
- **Smart Notifications**: "New event near you", "Business you follow has update"
- **Map Layers**: Toggle to see: Events, Businesses, Resources, Services
- **Personalized**: "Events you might like", "Businesses near you"
**Why It's Original**:
- **Proactive** information delivery (not just search)
- **Location-based** discovery
- Combines **business + community** information
- **Single platform** for all local info
**Implementation**:
- Event management system
- Business update feed
- Notification system
- Map layer toggles
---
## Quick Implementation Guide
### Phase 1: Foundation (Weeks 1-4)
**1. Universal Resource Discovery** (High Impact)
- Add "community listings" to existing resource system
- Unified search: business + community resources
- Simple listing form for community members
- Map view with all resources
**2. Information Hub** (High Engagement)
- Event calendar (basic)
- Business updates feed
- News aggregation
- Notification system
### Phase 2: Economic & Social (Weeks 5-8)
**3. Local First Marketplace**
- Business product/service catalog
- Local alternative suggestions
- Points system (basic)
**4. Skill Exchange Network**
- Skills directory
- User skill profiles
- Basic matching
### Phase 3: Advanced Coordination (Weeks 9-12)
**5. Transportation Network**
- Ride sharing
- Delivery coordination
- Route matching
---
## Database Schema (Minimal)
```sql
-- Community resource listings
CREATE TABLE community_listings (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
title VARCHAR(255),
type VARCHAR(50), -- tool, equipment, service, skill, need
description TEXT,
location POINT,
availability JSONB,
status VARCHAR(20),
created_at TIMESTAMP
);
-- Skills/services
CREATE TABLE user_skills (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
skill_name VARCHAR(100),
category VARCHAR(50),
rate DECIMAL(10,2),
availability JSONB,
location POINT
);
-- Transportation
CREATE TABLE ride_offers (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
route JSONB, -- {from, to, waypoints}
departure_time TIMESTAMP,
seats_available INT,
cost_per_person DECIMAL(10,2)
);
CREATE TABLE ride_requests (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
from_location POINT,
to_location POINT,
desired_time TIMESTAMP,
seats_needed INT
);
-- Business products/services (for local marketplace)
CREATE TABLE business_products (
id UUID PRIMARY KEY,
business_id UUID REFERENCES organizations(id),
name VARCHAR(255),
description TEXT,
category VARCHAR(50),
price DECIMAL(10,2),
available BOOLEAN
);
```
---
## Success Metrics
### Problem-Solving Metrics:
- **Resource Discovery**: % of "I need X" queries that find matches
- **Local Shopping**: % of users who choose local over online
- **Skill Matches**: Number of successful skill/service connections
- **Transportation**: Number of rides/deliveries coordinated
- **Information**: % of users who check platform for local info
### Engagement Metrics:
- **Daily Active Users**: Target 500+ by month 6
- **Query Volume**: Searches per day
- **Match Success Rate**: % of queries resulting in connections
- **Return Usage**: % of weekly active users
---
## Why These Features Work
1. **Solve Real Problems**: Not generic features - address actual small-city pain points
2. **Daily Use Value**: People will use these regularly, not just occasionally
3. **Leverage Platform**: Use existing matching, map, business network
4. **Network Effects**: More users = better matches = more value
5. **Economic Model**: Creates value for businesses and community
---
## The Vision
**"The Operating System for Small City Life"**
One platform where you can:
- Find anything (resources, services, skills, people)
- Connect with anyone (businesses, community, neighbors)
- Solve any local problem (coordination, information, economy)
- Keep money local (support local businesses)
- Build community (events, sharing, cooperation)
Not just a resource matching platform - **the infrastructure for small city life**.
---
**Last Updated**: 2025-01-27
**Status**: Ready for Implementation

View File

@ -0,0 +1,958 @@
# Product & Service Discovery: "I Don't Know Who Has What" - Full Concept
## Executive Summary
This document provides a complete concept for implementing a **Universal Resource Discovery** system that allows users to find products, services, skills, and resources (both from businesses and community members) through a unified search and matching platform. This extends the existing industrial symbiosis matching engine to include consumer products, services, and community resources.
---
## Problem Statement
In small cities like Bugulma, residents face a critical information gap:
- **"I need X, but I don't know who has it"**
- Products and services exist but are hard to discover
- Information is fragmented across social media, word-of-mouth, and physical locations
- Businesses have surplus/underutilized resources but no way to connect with needs
- Community members have skills/tools but no visibility
**Current Platform Gap**: The platform currently matches industrial resources (heat, water, waste) between businesses. It doesn't handle:
- Consumer products and services
- Community member listings
- Skills and expertise matching
- Real-time availability
---
## Solution Overview
### Core Concept: Unified Discovery Platform
A single search interface that finds:
1. **Business Products**: Items/services businesses offer
2. **Business Surplus**: Underutilized resources businesses want to share/rent
3. **Community Listings**: Items/services community members offer
4. **Skills & Services**: People offering expertise or services
5. **Needs**: People looking for specific items/services
All searchable through:
- Natural language queries: "I need a van", "Who fixes computers?"
- Category filters: Products, Services, Tools, Skills
- Location-based ranking
- Real-time availability
---
## Architecture Overview
### System Components
```
┌─────────────────────────────────────────────────────────┐
│ Frontend Layer │
│ - Universal Search Interface │
│ - Product/Service Listing Pages │
│ - Map View with Discovery Layer │
│ - User Dashboard (My Listings, Requests) │
└──────────────────────┬────────────────────────────────────┘
┌──────────────────────▼────────────────────────────────────┐
│ API Layer │
│ - /api/v1/discovery/search │
│ - /api/v1/discovery/listings │
│ - /api/v1/discovery/services │
│ - /api/v1/discovery/skills │
└──────────────────────┬────────────────────────────────────┘
┌──────────────────────▼────────────────────────────────────┐
│ Discovery Service Layer │
│ - Search Engine (text, category, location) │
│ - Matching Algorithm (extends existing matching) │
│ - Availability Manager │
│ - Notification Service │
└──────────────────────┬────────────────────────────────────┘
┌──────────────────────▼────────────────────────────────────┐
│ Data Layer │
│ - product_listings (business products) │
│ - service_listings (business services) │
│ - community_listings (community items/services) │
│ - skill_profiles (user skills) │
│ - discovery_requests (needs/wants) │
└───────────────────────────────────────────────────────────┘
```
---
## Data Model
### 1. Product Listings (Business Products)
**Purpose**: Businesses list products they sell/offer
```sql
CREATE TABLE product_listings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id TEXT REFERENCES organizations(id) ON DELETE CASCADE,
site_id TEXT REFERENCES sites(id),
-- Product Information
name VARCHAR(255) NOT NULL,
description TEXT,
category VARCHAR(100) NOT NULL, -- 'tools', 'equipment', 'materials', 'food', etc.
subcategory VARCHAR(100),
brand VARCHAR(100),
model VARCHAR(100),
-- Pricing
price DECIMAL(10,2),
price_unit VARCHAR(50), -- 'per_item', 'per_hour', 'per_day', 'per_month'
currency VARCHAR(3) DEFAULT 'EUR',
negotiable BOOLEAN DEFAULT false,
-- Availability
quantity_available INTEGER, -- NULL = unlimited
availability_status VARCHAR(20) DEFAULT 'available', -- 'available', 'limited', 'out_of_stock', 'rental_only'
availability_schedule JSONB, -- {days: ['monday', 'wednesday'], times: ['09:00-17:00']}
-- Location
pickup_location POINT, -- PostGIS geography point
delivery_available BOOLEAN DEFAULT false,
delivery_radius_km DECIMAL(5,2),
delivery_cost DECIMAL(10,2),
-- Media
images TEXT[], -- Array of image URLs
specifications JSONB, -- Flexible product specs
-- Metadata
tags TEXT[], -- Searchable tags
search_keywords TEXT, -- Full-text search field
verified BOOLEAN DEFAULT false, -- Business verification
-- Status
status VARCHAR(20) DEFAULT 'active', -- 'active', 'inactive', 'sold_out', 'archived'
featured BOOLEAN DEFAULT false,
-- Timestamps
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE, -- Optional expiration
-- Indexes
CONSTRAINT fk_organization FOREIGN KEY (organization_id) REFERENCES organizations(id)
);
-- Indexes for product_listings
CREATE INDEX idx_product_listings_org ON product_listings(organization_id);
CREATE INDEX idx_product_listings_category ON product_listings(category);
CREATE INDEX idx_product_listings_status ON product_listings(status);
CREATE INDEX idx_product_listings_location ON product_listings USING GIST(pickup_location);
CREATE INDEX idx_product_listings_search ON product_listings USING GIN(to_tsvector('russian', name || ' ' || COALESCE(description, '') || ' ' || COALESCE(search_keywords, '')));
CREATE INDEX idx_product_listings_tags ON product_listings USING GIN(tags);
```
### 2. Service Listings (Business Services)
**Purpose**: Businesses list services they offer
```sql
CREATE TABLE service_listings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id TEXT REFERENCES organizations(id) ON DELETE CASCADE,
site_id TEXT REFERENCES sites(id),
-- Service Information
name VARCHAR(255) NOT NULL,
description TEXT,
category VARCHAR(100) NOT NULL, -- 'repair', 'consulting', 'delivery', 'rental', etc.
subcategory VARCHAR(100),
-- Pricing
price DECIMAL(10,2),
price_type VARCHAR(50), -- 'fixed', 'hourly', 'daily', 'project_based', 'quote'
currency VARCHAR(3) DEFAULT 'EUR',
negotiable BOOLEAN DEFAULT false,
-- Availability
availability_status VARCHAR(20) DEFAULT 'available',
availability_schedule JSONB, -- {days: ['monday-friday'], times: ['09:00-18:00']}
booking_required BOOLEAN DEFAULT false,
min_advance_booking_hours INTEGER,
-- Service Area
service_location POINT, -- Where service is provided
service_radius_km DECIMAL(5,2), -- How far they travel
on_site_service BOOLEAN DEFAULT true,
remote_service BOOLEAN DEFAULT false,
-- Requirements
requirements TEXT, -- What customer needs to provide
duration_estimate VARCHAR(100), -- '1 hour', '2-3 days', etc.
-- Media
images TEXT[],
portfolio_urls TEXT[], -- Links to portfolio/examples
-- Metadata
tags TEXT[],
search_keywords TEXT,
certifications TEXT[], -- Relevant certifications
verified BOOLEAN DEFAULT false,
-- Status
status VARCHAR(20) DEFAULT 'active',
featured BOOLEAN DEFAULT false,
-- Timestamps
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT fk_organization FOREIGN KEY (organization_id) REFERENCES organizations(id)
);
-- Indexes for service_listings
CREATE INDEX idx_service_listings_org ON service_listings(organization_id);
CREATE INDEX idx_service_listings_category ON service_listings(category);
CREATE INDEX idx_service_listings_status ON service_listings(status);
CREATE INDEX idx_service_listings_location ON service_listings USING GIST(service_location);
CREATE INDEX idx_service_listings_search ON service_listings USING GIN(to_tsvector('russian', name || ' ' || COALESCE(description, '') || ' ' || COALESCE(search_keywords, '')));
```
### 3. Community Listings
**Purpose**: Community members list items/services they offer
```sql
CREATE TABLE community_listings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
-- Listing Information
title VARCHAR(255) NOT NULL,
description TEXT,
listing_type VARCHAR(50) NOT NULL, -- 'product', 'service', 'tool', 'skill', 'need'
category VARCHAR(100) NOT NULL,
subcategory VARCHAR(100),
-- For Products/Tools
condition VARCHAR(50), -- 'new', 'like_new', 'good', 'fair', 'needs_repair'
price DECIMAL(10,2), -- NULL = free
price_type VARCHAR(50), -- 'free', 'sale', 'rent', 'trade', 'borrow'
-- For Services/Skills
service_type VARCHAR(50), -- 'offering', 'seeking'
rate DECIMAL(10,2),
rate_type VARCHAR(50), -- 'hourly', 'fixed', 'negotiable', 'free'
-- Availability
availability_status VARCHAR(20) DEFAULT 'available',
availability_schedule JSONB,
quantity_available INTEGER, -- For products
-- Location
location POINT,
pickup_available BOOLEAN DEFAULT true,
delivery_available BOOLEAN DEFAULT false,
delivery_radius_km DECIMAL(5,2),
-- Media
images TEXT[],
-- Metadata
tags TEXT[],
search_keywords TEXT,
-- Trust & Verification
user_rating DECIMAL(3,2), -- Average rating from reviews
review_count INTEGER DEFAULT 0,
verified BOOLEAN DEFAULT false, -- Platform verification
-- Status
status VARCHAR(20) DEFAULT 'active', -- 'active', 'reserved', 'completed', 'archived'
-- Timestamps
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE, -- Auto-archive after X days
CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id)
);
-- Indexes for community_listings
CREATE INDEX idx_community_listings_user ON community_listings(user_id);
CREATE INDEX idx_community_listings_type ON community_listings(listing_type);
CREATE INDEX idx_community_listings_category ON community_listings(category);
CREATE INDEX idx_community_listings_status ON community_listings(status);
CREATE INDEX idx_community_listings_location ON community_listings USING GIST(location);
CREATE INDEX idx_community_listings_search ON community_listings USING GIN(to_tsvector('russian', title || ' ' || COALESCE(description, '') || ' ' || COALESCE(search_keywords, '')));
```
### 4. Skill Profiles
**Purpose**: Users list skills they can offer
```sql
CREATE TABLE skill_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
-- Skill Information
skill_name VARCHAR(255) NOT NULL,
description TEXT,
category VARCHAR(100) NOT NULL, -- 'technical', 'creative', 'professional', 'manual', 'language', etc.
subcategory VARCHAR(100),
-- Service Details
service_type VARCHAR(50) NOT NULL, -- 'offering', 'seeking', 'both'
experience_years INTEGER,
level VARCHAR(50), -- 'beginner', 'intermediate', 'advanced', 'expert'
-- Pricing
rate DECIMAL(10,2),
rate_type VARCHAR(50), -- 'hourly', 'fixed', 'negotiable', 'free', 'trade'
currency VARCHAR(3) DEFAULT 'EUR',
-- Availability
availability_status VARCHAR(20) DEFAULT 'available',
availability_schedule JSONB,
remote_available BOOLEAN DEFAULT false,
on_site_available BOOLEAN DEFAULT true,
-- Location
service_location POINT,
service_radius_km DECIMAL(5,2),
-- Credentials
certifications TEXT[],
portfolio_urls TEXT[],
education TEXT,
-- Metadata
tags TEXT[],
search_keywords TEXT,
-- Trust
user_rating DECIMAL(3,2),
review_count INTEGER DEFAULT 0,
verified BOOLEAN DEFAULT false,
-- Status
status VARCHAR(20) DEFAULT 'active',
-- Timestamps
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id)
);
-- Indexes for skill_profiles
CREATE INDEX idx_skill_profiles_user ON skill_profiles(user_id);
CREATE INDEX idx_skill_profiles_category ON skill_profiles(category);
CREATE INDEX idx_skill_profiles_status ON skill_profiles(status);
CREATE INDEX idx_skill_profiles_location ON skill_profiles USING GIST(service_location);
CREATE INDEX idx_skill_profiles_search ON skill_profiles USING GIN(to_tsvector('russian', skill_name || ' ' || COALESCE(description, '') || ' ' || COALESCE(search_keywords, '')));
```
### 5. Discovery Requests
**Purpose**: Users can post what they're looking for
```sql
CREATE TABLE discovery_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
-- Request Information
title VARCHAR(255) NOT NULL,
description TEXT,
request_type VARCHAR(50) NOT NULL, -- 'product', 'service', 'skill', 'tool', 'general'
category VARCHAR(100),
-- Requirements
requirements TEXT, -- Specific requirements
budget_min DECIMAL(10,2),
budget_max DECIMAL(10,2),
urgency VARCHAR(50), -- 'low', 'medium', 'high', 'urgent'
-- Location
location POINT, -- Where needed
max_distance_km DECIMAL(5,2),
pickup_preferred BOOLEAN DEFAULT true,
delivery_acceptable BOOLEAN DEFAULT false,
-- Timeline
needed_by TIMESTAMP WITH TIME ZONE,
flexible_timeline BOOLEAN DEFAULT true,
-- Status
status VARCHAR(20) DEFAULT 'open', -- 'open', 'matched', 'fulfilled', 'closed', 'expired'
match_count INTEGER DEFAULT 0, -- Number of matches found
-- Timestamps
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE, -- Auto-close after X days
CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id)
);
-- Indexes for discovery_requests
CREATE INDEX idx_discovery_requests_user ON discovery_requests(user_id);
CREATE INDEX idx_discovery_requests_type ON discovery_requests(request_type);
CREATE INDEX idx_discovery_requests_status ON discovery_requests(status);
CREATE INDEX idx_discovery_requests_location ON discovery_requests USING GIST(location);
CREATE INDEX idx_discovery_requests_search ON discovery_requests USING GIN(to_tsvector('russian', title || ' ' || COALESCE(description, '')));
```
### 6. Listing Interactions
**Purpose**: Track views, contacts, favorites, reviews
```sql
CREATE TABLE listing_interactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
-- What was interacted with
listing_type VARCHAR(50) NOT NULL, -- 'product', 'service', 'community', 'skill'
listing_id UUID NOT NULL, -- References the specific listing table
-- Interaction Type
interaction_type VARCHAR(50) NOT NULL, -- 'view', 'contact', 'favorite', 'share', 'report'
-- Contact Details (if interaction_type = 'contact')
contact_method VARCHAR(50), -- 'platform_message', 'phone', 'email', 'visit'
message TEXT, -- Initial message if any
-- Metadata
metadata JSONB, -- Flexible additional data
-- Timestamps
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id)
);
-- Indexes
CREATE INDEX idx_listing_interactions_user ON listing_interactions(user_id);
CREATE INDEX idx_listing_interactions_listing ON listing_interactions(listing_type, listing_id);
CREATE INDEX idx_listing_interactions_type ON listing_interactions(interaction_type);
```
---
## API Design
### 1. Universal Search Endpoint
**Endpoint**: `GET /api/v1/discovery/search`
**Purpose**: Single search interface for all product/service discovery
**Query Parameters**:
```typescript
{
q: string; // Search query (natural language)
category?: string; // Filter by category
listing_type?: string; // 'product', 'service', 'community', 'skill', 'all'
location?: { // User location for distance ranking
lat: number;
lng: number;
};
max_distance_km?: number; // Maximum distance (default: 25km)
price_min?: number;
price_max?: number;
availability?: string; // 'available', 'all'
verified_only?: boolean; // Only show verified listings
sort?: string; // 'relevance', 'distance', 'price_low', 'price_high', 'rating'
page?: number;
limit?: number;
}
```
**Response**:
```typescript
{
results: Array<{
id: string;
type: 'product' | 'service' | 'community' | 'skill';
title: string;
description: string;
category: string;
price?: number;
price_type?: string;
location: {
lat: number;
lng: number;
};
distance_km?: number;
organization_id?: string; // If business listing
organization_name?: string;
user_id?: string; // If community listing
user_name?: string;
rating?: number;
review_count?: number;
verified: boolean;
images: string[];
availability_status: string;
match_score: number; // Relevance score
}>;
total: number;
page: number;
limit: number;
facets: {
categories: Array<{name: string, count: number}>;
price_ranges: Array<{min: number, max: number, count: number}>;
distances: Array<{range: string, count: number}>;
};
}
```
### 2. Listing Management Endpoints
#### Create Product Listing (Business)
**POST** `/api/v1/discovery/products`
- Requires: Business authentication
- Body: Product listing data
#### Create Service Listing (Business)
**POST** `/api/v1/discovery/services`
- Requires: Business authentication
- Body: Service listing data
#### Create Community Listing
**POST** `/api/v1/discovery/community`
- Requires: User authentication
- Body: Community listing data
#### Create Skill Profile
**POST** `/api/v1/discovery/skills`
- Requires: User authentication
- Body: Skill profile data
#### Create Discovery Request
**POST** `/api/v1/discovery/requests`
- Requires: User authentication
- Body: Request data
#### Get Listing Details
**GET** `/api/v1/discovery/listings/:type/:id`
- `type`: 'product', 'service', 'community', 'skill'
- Returns: Full listing details
#### Update Listing
**PUT** `/api/v1/discovery/listings/:type/:id`
- Requires: Ownership verification
#### Delete Listing
**DELETE** `/api/v1/discovery/listings/:type/:id`
- Requires: Ownership verification
### 3. User Dashboard Endpoints
#### Get User Listings
**GET** `/api/v1/discovery/my-listings`
- Returns: All listings created by user
#### Get User Requests
**GET** `/api/v1/discovery/my-requests`
- Returns: All discovery requests by user
#### Get Favorites
**GET** `/api/v1/discovery/favorites`
- Returns: User's favorited listings
#### Contact Listing Owner
**POST** `/api/v1/discovery/listings/:type/:id/contact`
- Creates interaction record
- Sends notification to owner
- Returns: Contact information or initiates platform messaging
---
## Search & Matching Algorithm
### Multi-Stage Search Pipeline
```
1. Query Processing
2. Text Search (Full-Text)
3. Category Filtering
4. Location-Based Filtering
5. Availability Check
6. Relevance Scoring
7. Ranking & Sorting
8. Result Formatting
```
### Relevance Scoring Formula
```go
func calculateRelevanceScore(listing Listing, query SearchQuery) float64 {
score := 0.0
// Text Match Score (0-40 points)
textScore := calculateTextMatch(listing, query.Text)
score += textScore * 0.4
// Category Match (0-20 points)
if listing.Category == query.Category {
score += 20.0
} else if listing.Subcategory == query.Category {
score += 15.0
}
// Distance Score (0-20 points)
distanceScore := calculateDistanceScore(listing.Location, query.Location, query.MaxDistance)
score += distanceScore * 0.2
// Trust Score (0-10 points)
trustScore := calculateTrustScore(listing)
score += trustScore * 0.1
// Availability Score (0-10 points)
availabilityScore := calculateAvailabilityScore(listing)
score += availabilityScore * 0.1
return score / 100.0 // Normalize to 0-1
}
func calculateTextMatch(listing Listing, queryText string) float64 {
// Full-text search relevance
// Uses PostgreSQL ts_rank or similar
// Returns 0-100
}
func calculateDistanceScore(listingLocation, queryLocation Point, maxDistance float64) float64 {
distance := calculateHaversineDistance(listingLocation, queryLocation)
if distance > maxDistance {
return 0.0
}
// Closer = higher score
// Linear decay: score = 100 * (1 - distance/maxDistance)
return 100.0 * (1.0 - distance/maxDistance)
}
func calculateTrustScore(listing Listing) float64 {
score := 50.0 // Base score
if listing.Verified {
score += 30.0
}
if listing.Rating > 0 {
score += listing.Rating * 20.0 // Rating is 0-5, so max +100
}
// Business listings get bonus
if listing.OrganizationID != "" {
score += 10.0
}
return min(score, 100.0)
}
func calculateAvailabilityScore(listing Listing) float64 {
if listing.AvailabilityStatus == "available" {
return 100.0
} else if listing.AvailabilityStatus == "limited" {
return 70.0
} else if listing.AvailabilityStatus == "reserved" {
return 30.0
}
return 0.0
}
```
---
## Frontend Components
### 1. Universal Search Bar
**Component**: `DiscoverySearchBar.tsx`
**Features**:
- Natural language input
- Category dropdown
- Location autocomplete
- Quick filters (price, distance, verified)
- Voice input (optional)
**Location**: Top of discovery page, persistent in header
### 2. Search Results Page
**Component**: `DiscoverySearchResults.tsx`
**Layout**:
- Left sidebar: Filters (category, price, distance, availability)
- Main area: Results grid/list
- Map view toggle
- Sort options
**Result Card**:
- Image thumbnail
- Title and description
- Price/rate
- Distance
- Rating (if available)
- Verified badge
- Quick actions (contact, favorite, share)
### 3. Listing Detail Page
**Component**: `ListingDetailPage.tsx`
**Sections**:
- Image gallery
- Title, description, category
- Price/rate details
- Location map
- Availability schedule
- Owner information (business or user)
- Reviews/ratings
- Contact button
- Share button
### 4. Create Listing Forms
**Components**:
- `CreateProductListingForm.tsx` (Business)
- `CreateServiceListingForm.tsx` (Business)
- `CreateCommunityListingForm.tsx` (User)
- `CreateSkillProfileForm.tsx` (User)
- `CreateDiscoveryRequestForm.tsx` (User)
**Features**:
- Step-by-step wizard
- Image upload
- Location picker (map)
- Category selection
- Price/availability inputs
- Preview before submit
### 5. User Dashboard
**Component**: `DiscoveryDashboard.tsx`
**Tabs**:
- My Listings
- My Requests
- Favorites
- Messages/Contacts
- Activity History
### 6. Map View Integration
**Enhancement**: Add discovery layer to existing map
**Features**:
- Toggle layer: "Show Products/Services"
- Clustering for dense areas
- Click marker → Show listing preview
- Filter by category on map
---
## Implementation Phases
### Phase 1: Foundation (Weeks 1-3)
**Backend**:
1. Database migrations for all tables
2. Domain models (Go structs)
3. Repository layer
4. Basic CRUD endpoints
5. Simple search (text + category)
**Frontend**:
1. Search interface
2. Results page
3. Listing detail page
4. Basic create forms
**Goal**: Users can search and view listings
---
### Phase 2: Enhanced Search (Weeks 4-5)
**Backend**:
1. Full-text search implementation
2. Location-based filtering
3. Relevance scoring algorithm
4. Faceted search (filters)
**Frontend**:
1. Advanced filters
2. Map integration
3. Sort options
4. Result pagination
**Goal**: Powerful search with location ranking
---
### Phase 3: User Features (Weeks 6-7)
**Backend**:
1. User authentication integration
2. Listing ownership verification
3. Favorites system
4. Contact/interaction tracking
5. Notification system
**Frontend**:
1. User dashboard
2. Create/edit listings
3. Favorites management
4. Contact forms
5. My listings page
**Goal**: Users can create and manage listings
---
### Phase 4: Trust & Quality (Weeks 8-9)
**Backend**:
1. Rating/review system
2. Verification process
3. Trust scoring
4. Reporting/moderation
**Frontend**:
1. Review interface
2. Verification badges
3. Trust indicators
4. Report functionality
**Goal**: Build trust and quality
---
### Phase 5: Advanced Features (Weeks 10-12)
**Backend**:
1. Availability scheduling
2. Booking system (for services)
3. Matching algorithm (requests ↔ listings)
4. Notification system (alerts for new matches)
5. Analytics
**Frontend**:
1. Availability calendar
2. Booking interface
3. Request matching
4. Notification center
5. Analytics dashboard
**Goal**: Complete discovery platform
---
## Integration with Existing Systems
### 1. Leverage Existing Matching Engine
**Extend** `matching.Service` to handle:
- Product/service matching (not just resource flows)
- Discovery request matching
- Cross-type matching (request ↔ listing)
### 2. Use Existing Map Infrastructure
**Extend** `MapView` component:
- Add discovery layer toggle
- Show product/service markers
- Cluster markers
- Click → show listing details
### 3. Integrate with Organizations
**Link** product/service listings to:
- Existing `organizations` table
- Business profiles
- Organization verification status
### 4. Use Existing Authentication
**Reuse**:
- User authentication system
- Business authentication
- Role-based access control
---
## Success Metrics
### Engagement Metrics
- **Search Queries**: Number of searches per day
- **Listing Views**: Views per listing
- **Contact Rate**: % of views that result in contact
- **Listing Creation**: New listings per week
- **Match Success**: % of requests that find matches
### Quality Metrics
- **Search Relevance**: % of users who find what they need
- **Response Rate**: % of contacts that get responses
- **User Satisfaction**: Average rating of platform
- **Listing Quality**: % of listings with complete information
### Business Metrics
- **Business Participation**: % of businesses with listings
- **Community Participation**: % of users with listings
- **Transaction Volume**: Estimated value of matches
- **Platform Growth**: New users per month
---
## Technical Considerations
### Performance
- **Search Speed**: < 500ms for typical queries
- **Caching**: Cache popular searches
- **Indexing**: Proper database indexes
- **Pagination**: Limit results per page
### Scalability
- **Full-Text Search**: Use PostgreSQL full-text or Elasticsearch
- **Location Queries**: Use PostGIS spatial indexes
- **Image Storage**: Use CDN for images
- **Notifications**: Use message queue for async notifications
### Security
- **Input Validation**: Validate all user inputs
- **SQL Injection**: Use parameterized queries
- **XSS Prevention**: Sanitize user-generated content
- **Rate Limiting**: Prevent abuse
- **Privacy**: Respect user privacy settings
---
## Next Steps
1. **Review & Approve**: Review this concept document
2. **Database Design**: Finalize schema
3. **API Specification**: Detailed API docs
4. **UI/UX Design**: Mockups and wireframes
5. **Technical Spike**: Proof of concept for search
6. **Implementation**: Start Phase 1
---
**Document Version**: 1.0
**Last Updated**: 2025-01-27
**Status**: Concept - Ready for Review

View File

@ -0,0 +1,684 @@
# Product & Service Discovery: Quick Start Implementation Guide
## Overview
This guide provides step-by-step instructions to implement the first phase of the Product & Service Discovery feature.
---
## Phase 1: Foundation (Weeks 1-3)
### Step 1: Database Migrations
Create migration file: `bugulma/backend/migrations/postgres/019_create_discovery_tables.up.sql`
```sql
-- +migrate Up
-- Product Listings (Business Products)
CREATE TABLE product_listings (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
organization_id TEXT REFERENCES organizations(id) ON DELETE CASCADE,
site_id TEXT REFERENCES sites(id),
name VARCHAR(255) NOT NULL,
description TEXT,
category VARCHAR(100) NOT NULL,
subcategory VARCHAR(100),
brand VARCHAR(100),
model VARCHAR(100),
price DECIMAL(10,2),
price_unit VARCHAR(50),
currency VARCHAR(3) DEFAULT 'EUR',
negotiable BOOLEAN DEFAULT false,
quantity_available INTEGER,
availability_status VARCHAR(20) DEFAULT 'available',
availability_schedule JSONB,
pickup_location POINT,
delivery_available BOOLEAN DEFAULT false,
delivery_radius_km DECIMAL(5,2),
delivery_cost DECIMAL(10,2),
images TEXT[],
specifications JSONB,
tags TEXT[],
search_keywords TEXT,
verified BOOLEAN DEFAULT false,
status VARCHAR(20) DEFAULT 'active',
featured BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE
);
-- Service Listings (Business Services)
CREATE TABLE service_listings (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
organization_id TEXT REFERENCES organizations(id) ON DELETE CASCADE,
site_id TEXT REFERENCES sites(id),
name VARCHAR(255) NOT NULL,
description TEXT,
category VARCHAR(100) NOT NULL,
subcategory VARCHAR(100),
price DECIMAL(10,2),
price_type VARCHAR(50),
currency VARCHAR(3) DEFAULT 'EUR',
negotiable BOOLEAN DEFAULT false,
availability_status VARCHAR(20) DEFAULT 'available',
availability_schedule JSONB,
booking_required BOOLEAN DEFAULT false,
min_advance_booking_hours INTEGER,
service_location POINT,
service_radius_km DECIMAL(5,2),
on_site_service BOOLEAN DEFAULT true,
remote_service BOOLEAN DEFAULT false,
requirements TEXT,
duration_estimate VARCHAR(100),
images TEXT[],
portfolio_urls TEXT[],
tags TEXT[],
search_keywords TEXT,
certifications TEXT[],
verified BOOLEAN DEFAULT false,
status VARCHAR(20) DEFAULT 'active',
featured BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Community Listings
CREATE TABLE community_listings (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
listing_type VARCHAR(50) NOT NULL,
category VARCHAR(100) NOT NULL,
subcategory VARCHAR(100),
condition VARCHAR(50),
price DECIMAL(10,2),
price_type VARCHAR(50),
service_type VARCHAR(50),
rate DECIMAL(10,2),
rate_type VARCHAR(50),
availability_status VARCHAR(20) DEFAULT 'available',
availability_schedule JSONB,
quantity_available INTEGER,
location POINT,
pickup_available BOOLEAN DEFAULT true,
delivery_available BOOLEAN DEFAULT false,
delivery_radius_km DECIMAL(5,2),
images TEXT[],
tags TEXT[],
search_keywords TEXT,
user_rating DECIMAL(3,2),
review_count INTEGER DEFAULT 0,
verified BOOLEAN DEFAULT false,
status VARCHAR(20) DEFAULT 'active',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE
);
-- Indexes
CREATE INDEX idx_product_listings_org ON product_listings(organization_id);
CREATE INDEX idx_product_listings_category ON product_listings(category);
CREATE INDEX idx_product_listings_status ON product_listings(status);
CREATE INDEX idx_product_listings_location ON product_listings USING GIST(pickup_location);
CREATE INDEX idx_product_listings_search ON product_listings USING GIN(to_tsvector('russian', name || ' ' || COALESCE(description, '') || ' ' || COALESCE(search_keywords, '')));
CREATE INDEX idx_service_listings_org ON service_listings(organization_id);
CREATE INDEX idx_service_listings_category ON service_listings(category);
CREATE INDEX idx_service_listings_status ON service_listings(status);
CREATE INDEX idx_service_listings_location ON service_listings USING GIST(service_location);
CREATE INDEX idx_service_listings_search ON service_listings USING GIN(to_tsvector('russian', name || ' ' || COALESCE(description, '') || ' ' || COALESCE(search_keywords, '')));
CREATE INDEX idx_community_listings_user ON community_listings(user_id);
CREATE INDEX idx_community_listings_type ON community_listings(listing_type);
CREATE INDEX idx_community_listings_category ON community_listings(category);
CREATE INDEX idx_community_listings_status ON community_listings(status);
CREATE INDEX idx_community_listings_location ON community_listings USING GIST(location);
CREATE INDEX idx_community_listings_search ON community_listings USING GIN(to_tsvector('russian', title || ' ' || COALESCE(description, '') || ' ' || COALESCE(search_keywords, '')));
```
Create down migration: `019_create_discovery_tables.down.sql`
```sql
-- +migrate Down
DROP TABLE IF EXISTS community_listings;
DROP TABLE IF EXISTS service_listings;
DROP TABLE IF EXISTS product_listings;
```
---
### Step 2: Domain Models
Create: `bugulma/backend/internal/domain/product_listing.go`
```go
package domain
import (
"time"
"github.com/lib/pq"
)
type ProductListing struct {
ID string `gorm:"primaryKey;type:text"`
OrganizationID string `gorm:"type:text;index"`
SiteID *string `gorm:"type:text"`
Name string `gorm:"type:varchar(255);not null"`
Description *string `gorm:"type:text"`
Category string `gorm:"type:varchar(100);not null;index"`
Subcategory *string `gorm:"type:varchar(100)"`
Brand *string `gorm:"type:varchar(100)"`
Model *string `gorm:"type:varchar(100)"`
Price *float64 `gorm:"type:decimal(10,2)"`
PriceUnit *string `gorm:"type:varchar(50)"`
Currency string `gorm:"type:varchar(3);default:'EUR'"`
Negotiable bool `gorm:"default:false"`
QuantityAvailable *int
AvailabilityStatus string `gorm:"type:varchar(20);default:'available'"`
AvailabilitySchedule *string `gorm:"type:jsonb"`
PickupLocation *string `gorm:"type:point"` // PostGIS point
DeliveryAvailable bool `gorm:"default:false"`
DeliveryRadiusKm *float64 `gorm:"type:decimal(5,2)"`
DeliveryCost *float64 `gorm:"type:decimal(10,2)"`
Images pq.StringArray `gorm:"type:text[]"`
Specifications *string `gorm:"type:jsonb"`
Tags pq.StringArray `gorm:"type:text[]"`
SearchKeywords *string `gorm:"type:text"`
Verified bool `gorm:"default:false"`
Status string `gorm:"type:varchar(20);default:'active';index"`
Featured bool `gorm:"default:false"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
ExpiresAt *time.Time
// Relationships
Organization *Organization `gorm:"foreignKey:OrganizationID"`
Site *Site `gorm:"foreignKey:SiteID"`
}
```
Create similar models for `ServiceListing` and `CommunityListing`.
---
### Step 3: Repository Layer
Create: `bugulma/backend/internal/repository/product_listing_repository.go`
```go
package repository
import (
"context"
"bugulma/backend/internal/domain"
"gorm.io/gorm"
)
type ProductListingRepository interface {
Create(ctx context.Context, listing *domain.ProductListing) error
GetByID(ctx context.Context, id string) (*domain.ProductListing, error)
Update(ctx context.Context, listing *domain.ProductListing) error
Delete(ctx context.Context, id string) error
Search(ctx context.Context, query SearchQuery) ([]*domain.ProductListing, int64, error)
GetByOrganization(ctx context.Context, orgID string) ([]*domain.ProductListing, error)
}
type productListingRepository struct {
db *gorm.DB
}
func NewProductListingRepository(db *gorm.DB) ProductListingRepository {
return &productListingRepository{db: db}
}
func (r *productListingRepository) Create(ctx context.Context, listing *domain.ProductListing) error {
return r.db.WithContext(ctx).Create(listing).Error
}
func (r *productListingRepository) GetByID(ctx context.Context, id string) (*domain.ProductListing, error) {
var listing domain.ProductListing
err := r.db.WithContext(ctx).
Preload("Organization").
Preload("Site").
First(&listing, "id = ?", id).Error
if err != nil {
return nil, err
}
return &listing, nil
}
func (r *productListingRepository) Search(ctx context.Context, query SearchQuery) ([]*domain.ProductListing, int64, error) {
var listings []*domain.ProductListing
var total int64
db := r.db.WithContext(ctx).Model(&domain.ProductListing{}).
Where("status = ?", "active")
// Text search
if query.Text != "" {
db = db.Where(
"to_tsvector('russian', name || ' ' || COALESCE(description, '') || ' ' || COALESCE(search_keywords, '')) @@ plainto_tsquery('russian', ?)",
query.Text,
)
}
// Category filter
if query.Category != "" {
db = db.Where("category = ?", query.Category)
}
// Location filter (if provided)
if query.Location != nil {
// Use PostGIS ST_DWithin for distance filtering
db = db.Where(
"ST_DWithin(pickup_location::geography, ST_MakePoint(?, ?)::geography, ?)",
query.Location.Lng,
query.Location.Lat,
query.MaxDistanceKm*1000, // Convert km to meters
)
}
// Price filter
if query.PriceMin != nil {
db = db.Where("price >= ?", *query.PriceMin)
}
if query.PriceMax != nil {
db = db.Where("price <= ?", *query.PriceMax)
}
// Count total
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
// Apply pagination and ordering
offset := (query.Page - 1) * query.Limit
err := db.
Preload("Organization").
Offset(offset).
Limit(query.Limit).
Order("featured DESC, created_at DESC").
Find(&listings).Error
return listings, total, err
}
type SearchQuery struct {
Text string
Category string
Location *struct {
Lat float64
Lng float64
}
MaxDistanceKm float64
PriceMin *float64
PriceMax *float64
Page int
Limit int
}
```
---
### Step 4: Service Layer
Create: `bugulma/backend/internal/service/discovery_service.go`
```go
package service
import (
"context"
"bugulma/backend/internal/domain"
"bugulma/backend/internal/repository"
)
type DiscoveryService interface {
SearchProducts(ctx context.Context, query SearchQuery) (*SearchResults, error)
SearchServices(ctx context.Context, query SearchQuery) (*SearchResults, error)
SearchCommunity(ctx context.Context, query SearchQuery) (*SearchResults, error)
UniversalSearch(ctx context.Context, query SearchQuery) (*UniversalSearchResults, error)
}
type discoveryService struct {
productRepo repository.ProductListingRepository
serviceRepo repository.ServiceListingRepository
communityRepo repository.CommunityListingRepository
}
func NewDiscoveryService(
productRepo repository.ProductListingRepository,
serviceRepo repository.ServiceListingRepository,
communityRepo repository.CommunityListingRepository,
) DiscoveryService {
return &discoveryService{
productRepo: productRepo,
serviceRepo: serviceRepo,
communityRepo: communityRepo,
}
}
func (s *discoveryService) UniversalSearch(ctx context.Context, query SearchQuery) (*UniversalSearchResults, error) {
// Search all types in parallel
products, productsTotal, _ := s.productRepo.Search(ctx, query)
services, servicesTotal, _ := s.serviceRepo.Search(ctx, query)
community, communityTotal, _ := s.communityRepo.Search(ctx, query)
// Combine and rank results
results := &UniversalSearchResults{
Products: products,
Services: services,
Community: community,
Total: productsTotal + servicesTotal + communityTotal,
}
return results, nil
}
```
---
### Step 5: Handler Layer
Create: `bugulma/backend/internal/handler/discovery_handler.go`
```go
package handler
import (
"net/http"
"bugulma/backend/internal/service"
"github.com/gin-gonic/gin"
)
type DiscoveryHandler struct {
discoveryService service.DiscoveryService
}
func NewDiscoveryHandler(discoveryService service.DiscoveryService) *DiscoveryHandler {
return &DiscoveryHandler{
discoveryService: discoveryService,
}
}
// GET /api/v1/discovery/search
func (h *DiscoveryHandler) Search(c *gin.Context) {
var query service.SearchQuery
if err := c.ShouldBindQuery(&query); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Set defaults
if query.Page == 0 {
query.Page = 1
}
if query.Limit == 0 {
query.Limit = 20
}
if query.MaxDistanceKm == 0 {
query.MaxDistanceKm = 25.0
}
results, err := h.discoveryService.UniversalSearch(c.Request.Context(), query)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, results)
}
```
---
### Step 6: Routes
Update: `bugulma/backend/internal/routes/routes.go`
Add discovery routes:
```go
func RegisterDiscoveryRoutes(public, protected *gin.RouterGroup, discoveryHandler *handler.DiscoveryHandler) {
discovery := public.Group("/discovery")
{
discovery.GET("/search", discoveryHandler.Search)
discovery.GET("/products", discoveryHandler.GetProducts)
discovery.GET("/services", discoveryHandler.GetServices)
discovery.GET("/community", discoveryHandler.GetCommunity)
discovery.GET("/listings/:type/:id", discoveryHandler.GetListing)
}
discoveryProtected := protected.Group("/discovery")
{
discoveryProtected.POST("/products", discoveryHandler.CreateProduct)
discoveryProtected.POST("/services", discoveryHandler.CreateService)
discoveryProtected.POST("/community", discoveryHandler.CreateCommunity)
discoveryProtected.PUT("/listings/:type/:id", discoveryHandler.UpdateListing)
discoveryProtected.DELETE("/listings/:type/:id", discoveryHandler.DeleteListing)
}
}
```
---
### Step 7: Frontend - Search Component
Create: `bugulma/frontend/components/discovery/DiscoverySearchBar.tsx`
```typescript
import React, { useState } from 'react';
import { Search } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
interface DiscoverySearchBarProps {
onSearch: (query: string) => void;
}
export const DiscoverySearchBar: React.FC<DiscoverySearchBarProps> = ({ onSearch }) => {
const [query, setQuery] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSearch(query);
};
return (
<form onSubmit={handleSubmit} className="w-full max-w-2xl mx-auto">
<div className="flex gap-2">
<Input
type="text"
placeholder="I need a van, who fixes computers, looking for..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="flex-1"
/>
<Button type="submit">
<Search className="w-4 h-4 mr-2" />
Search
</Button>
</div>
</form>
);
};
```
---
### Step 8: Frontend - Results Page
Create: `bugulma/frontend/pages/DiscoveryPage.tsx`
```typescript
import React, { useState, useEffect } from 'react';
import { DiscoverySearchBar } from '@/components/discovery/DiscoverySearchBar';
import { DiscoveryResults } from '@/components/discovery/DiscoveryResults';
import { discoveryAPI } from '@/services/discovery-api';
export const DiscoveryPage: React.FC = () => {
const [query, setQuery] = useState('');
const [results, setResults] = useState(null);
const [loading, setLoading] = useState(false);
const handleSearch = async (searchQuery: string) => {
setQuery(searchQuery);
setLoading(true);
try {
const data = await discoveryAPI.search({
q: searchQuery,
page: 1,
limit: 20,
});
setResults(data);
} catch (error) {
console.error('Search error:', error);
} finally {
setLoading(false);
}
};
return (
<div className="container mx-auto py-8">
<h1 className="text-3xl font-bold mb-8">Find Products & Services</h1>
<DiscoverySearchBar onSearch={handleSearch} />
{loading && <div>Searching...</div>}
{results && <DiscoveryResults results={results} />}
</div>
);
};
```
---
### Step 9: Frontend - API Service
Create: `bugulma/frontend/services/discovery-api.ts`
```typescript
import { BaseService } from './base-service';
export interface SearchQuery {
q?: string;
category?: string;
listing_type?: string;
location?: { lat: number; lng: number };
max_distance_km?: number;
price_min?: number;
price_max?: number;
page?: number;
limit?: number;
}
export interface SearchResult {
id: string;
type: 'product' | 'service' | 'community';
title: string;
description: string;
category: string;
price?: number;
location: { lat: number; lng: number };
distance_km?: number;
organization_name?: string;
user_name?: string;
rating?: number;
verified: boolean;
images: string[];
}
export interface SearchResponse {
results: SearchResult[];
total: number;
page: number;
limit: number;
}
class DiscoveryService extends BaseService {
constructor() {
super('/api/v1/discovery');
}
async search(query: SearchQuery): Promise<SearchResponse> {
const params = new URLSearchParams();
if (query.q) params.append('q', query.q);
if (query.category) params.append('category', query.category);
if (query.location) {
params.append('lat', query.location.lat.toString());
params.append('lng', query.location.lng.toString());
}
if (query.max_distance_km) params.append('max_distance_km', query.max_distance_km.toString());
if (query.page) params.append('page', query.page.toString());
if (query.limit) params.append('limit', query.limit.toString());
return this.get<SearchResponse>(`/search?${params.toString()}`);
}
}
export const discoveryAPI = new DiscoveryService();
```
---
## Testing Checklist
- [ ] Database migrations run successfully
- [ ] Can create product listing (business)
- [ ] Can create service listing (business)
- [ ] Can create community listing (user)
- [ ] Search returns results
- [ ] Location filtering works
- [ ] Category filtering works
- [ ] Text search works
- [ ] Frontend search bar works
- [ ] Results display correctly
- [ ] Listing detail page works
---
## Next Steps After Phase 1
1. **Phase 2**: Enhanced search with relevance scoring
2. **Phase 3**: User dashboard and listing management
3. **Phase 4**: Trust system (ratings, reviews)
4. **Phase 5**: Advanced features (booking, matching)
---
**Last Updated**: 2025-01-27
**Status**: Implementation Guide

View File

@ -1,336 +0,0 @@
# Project Readiness Summary: Turash City Resource Graph
**Date**: November 2025
**Status**: 🟡 **CONCEPT COMPLETE - MVP IN PROGRESS**
**Next Milestone**: MVP Launch (Month 3)
---
## Executive Summary
The Turash platform concept is **100% complete** with comprehensive documentation covering all aspects from market analysis to technical implementation. The backend MVP is **70% complete** with core matching engine functional. Current priorities focus on completing MVP features and preparing for pilot launch.
**Overall Readiness**: 85% (Concept: 100%, Implementation: 70%, Funding: 100%)
---
## 📋 Concept Development Status
### ✅ **COMPLETE** (29/29 Documents)
The concept documentation is exceptionally comprehensive, covering:
#### Business & Strategy (100% Complete)
- **Market Analysis**: TAM/SAM/SOM, target segments, €2.5B+ opportunity
- **Competitive Analysis**: 30+ platforms analyzed, clear differentiation
- **Value Proposition**: Layered benefits (economic, environmental, regulatory)
- **Business Model**: Revenue streams, pricing, go-to-market strategy
- **Financial Projections**: 3-year cash flow, unit economics, €21M ARR potential
#### Technical Architecture (100% Complete)
- **System Overview**: Scalable platform for 50k+ businesses
- **Data Model**: Graph-based ontology with 15+ entity types
- **Matching Engine**: Multi-criteria algorithm with temporal, quality, economic scoring
- **Go 1.25 Stack**: Modern backend with Neo4j, PostGIS, NATS, Redis
- **APIs & Security**: REST/GraphQL, JWT, GDPR compliance
- **DevOps & Monitoring**: Kubernetes, Prometheus, CI/CD pipelines
#### Domain Knowledge (100% Complete)
- **Industrial Symbioses**: 21 types identified and categorized
- **Economic Models**: NPV, IRR, payback calculations
- **Environmental Impact**: CO₂ reduction, water conservation metrics
- **Research Literature**: 25+ academic papers reviewed
#### Planning & Execution (100% Complete)
- **18-Month Roadmap**: Monthly milestones, €2.5M budget allocation
- **Implementation Priorities**: MVP focus areas identified
- **Risk Assessment**: Technical, market, regulatory risks mitigated
- **Success Metrics**: Quantified KPIs and pivot triggers
### Key Insights from Concept Development
1. **Critical Mass Threshold**: 50+ participants required for network effects
2. **Local Clustering**: ≥70% participants within 5km for meaningful matches
3. **Data Quality Strategy**: Progressive refinement prevents "data quality death spiral"
4. **Hybrid Automation**: 80% automated, 20% facilitated matches
5. **Geographic Expansion**: Achieve 50+ participants before expanding
---
## 🔧 Backend Implementation Status
### ✅ **COMPLETE** (Core MVP - 70%)
#### Implemented Features
- **Domain Models**: Organizations, Sites, Resource Flows, Matches
- **Matching Engine**: Spatial pre-filtering, compatibility scoring, economic calculations
- **REST API**: Full CRUD operations with JWT authentication
- **Clean Architecture**: Domain-driven design with dependency injection
- **Go 1.25 Stack**: Latest language features, Gin framework
#### Advanced Features (POC Enhancements)
- **Temporal Matching**: Weekly schedules, seasonal availability
- **Data Quality Scoring**: Completeness, accuracy, consistency metrics
- **Economic Calculator**: NPV, IRR, payback period, CO₂ impact
- **Match History**: Versioning, audit trail, status tracking
- **Enhanced Filtering**: NACE sectors, certifications, risk assessment
- **Facilitator Service**: Hybrid automation model
- **Multi-Party Detection**: 3+ business opportunities
#### Current Architecture
```
backend/
├── cmd/server/ # ✅ Application entrypoint
├── internal/
│ ├── domain/ # ✅ Business entities and interfaces
│ ├── repository/ # ✅ In-memory implementations (ready for DB migration)
│ ├── service/ # ✅ Business logic (matching, auth, etc.)
│ ├── handler/ # ✅ HTTP handlers with validation
│ └── middleware/ # ✅ Auth, CORS, logging
└── pkg/config/ # ✅ Configuration management
```
### 🟡 **IN PROGRESS** (Database Integration - 30%)
#### Next Phase (Immediate Priorities)
- **Neo4j Integration**: Graph database for complex relationships
- **PostgreSQL + PostGIS**: Spatial queries and geospatial indexing
- **Redis Caching**: Match result caching, session management
- **WebSocket Notifications**: Real-time match updates
- **Batch Data Seeding**: Bugulma industrial park data import
#### Medium-term (MVP Completion)
- **Event-Driven Architecture**: NATS for background processing
- **IoT Integration**: Modbus, MQTT, OPC UA protocols
- **Data Quality Dashboard**: Business trust scores, data completeness
- **Facilitator Marketplace**: Expert assignment and workflow
- **Contract Management**: Negotiation tracking, agreement storage
---
## 🎯 Current Priorities (Month 1-3 MVP Focus)
### Immediate (Next 2 Weeks)
1. **Database Migration**: Neo4j + PostGIS setup and data migration
2. **Seed Data Import**: Berlin/Bugulma industrial facility data
3. **API Testing**: End-to-end matching workflow validation
4. **Performance Optimization**: Query optimization and caching
### Short-term (Month 1-2)
1. **MVP Feature Completion**: Temporal matching, economic calculations
2. **User Interface**: Basic React frontend for match visualization
3. **Pilot Preparation**: 20-30 businesses identified for beta testing
4. **Data Quality Pipeline**: Automated validation and scoring
### Medium-term (Month 3)
1. **Pilot Launch**: Berlin industrial + hospitality sector
2. **Match Conversion Tracking**: Success metrics and analytics
3. **Feedback Integration**: User testing and feature prioritization
4. **Funding Application Support**: Demo preparation for investors
---
## 📊 Readiness Metrics
### Concept Maturity: 100%
- Documentation completeness: 29/29 files ✅
- Technical specification: Complete ✅
- Business model validation: Complete ✅
- Market analysis: Complete ✅
- Risk assessment: Complete ✅
### Implementation Maturity: 70%
- Core matching engine: ✅ Functional
- API endpoints: ✅ Complete
- Authentication: ✅ Complete
- Data models: ✅ Complete
- Advanced features: ✅ POC complete
- Database integration: 🟡 In progress
- Frontend: ❌ Not started
- Testing: ⚠️ Basic unit tests only
### Funding Readiness: 100%
- Company registration: ✅ Complete
- Budget breakdowns: ✅ Program-specific
- Team documentation: ✅ Complete
- Technical documentation: ✅ Comprehensive
- Environmental impact: ✅ Quantified
- Business plan: ✅ Complete
---
## 🚧 Critical Path Dependencies
### Technical Dependencies
1. **Database Setup** → Matching Performance → User Experience
2. **Seed Data Quality** → Initial Matches → Network Effects
3. **API Stability** → Integration Readiness → Ecosystem Growth
4. **Performance Optimization** → Scalability → Enterprise Adoption
### Business Dependencies
1. **Pilot Success** → Case Studies → Market Momentum
2. **Data Acquisition** → Match Quality → User Value
3. **Partnerships** → Distribution Channels → Growth Acceleration
4. **Funding Secured** → Team Expansion → Development Velocity
### Risk Mitigation
- **Technical Risks**: Database migration, performance scaling
- **Market Risks**: Cold start problem, adoption barriers
- **Financial Risks**: Burn rate management, funding timelines
---
## 🎯 Success Milestones (Next 3 Months)
### Month 1: Foundation Completion
- ✅ Database integration complete
- ✅ Seed data imported (50+ businesses)
- ✅ Basic matching validated
- ✅ API documentation complete
### Month 2: MVP Assembly
- ✅ Advanced features integrated
- ✅ Frontend prototype functional
- ✅ Pilot businesses onboarded
- ✅ Performance benchmarks met
### Month 3: Pilot Launch
- ✅ 20+ businesses active
- ✅ 15+ matches identified
- ✅ 3+ expressions of interest
- ✅ Feedback collection initiated
---
## 💡 Key Strengths
### Technical Excellence
- **Modern Stack**: Go 1.25, graph databases, event-driven architecture
- **Scalable Design**: Clean architecture, domain-driven design
- **Advanced Algorithms**: Multi-criteria matching with temporal intelligence
- **Comprehensive Documentation**: 29 detailed specification files
### Business Readiness
- **Market Validation**: €2.5B TAM, clear competitive advantage
- **Financial Projections**: Realistic growth trajectory to €21M ARR
- **Funding Preparation**: Complete application packages for 5+ programs
- **Team Expertise**: Industrial symbiosis domain knowledge
### Environmental Impact
- **Quantified Benefits**: 1.2M tonnes CO₂ reduction potential
- **Regulatory Alignment**: CSRD, EU Green Deal compliance
- **Circular Economy**: Industrial symbiosis implementation
---
## ⚠️ Current Gaps & Risks
### Implementation Gaps
- **Database Migration**: In-memory → Neo4j/PostGIS (high priority)
- **Frontend Development**: No UI yet (medium priority)
- **Testing Coverage**: Limited automated tests (medium priority)
- **Production Deployment**: No cloud infrastructure (medium priority)
### Market Risks
- **Cold Start Problem**: Need initial critical mass for network effects
- **Data Quality**: Manual entry may limit initial adoption
- **Competition**: Several platforms in similar space
### Operational Risks
- **Team Scaling**: Need to hire 8+ engineers for full development
- **Funding Timeline**: Seed round needed for 18-month roadmap
- **Regulatory Changes**: EU environmental policies may evolve
---
## 🚀 Immediate Action Plan
### Week 1-2: Database Integration
1. Set up Neo4j cluster and PostGIS database
2. Migrate domain models and repositories
3. Implement spatial indexing and graph queries
4. Validate performance with seed data
### Week 3-4: MVP Completion
1. Integrate advanced matching features
2. Build basic React frontend for visualization
3. Implement WebSocket notifications
4. Prepare pilot data and user onboarding
### Month 2: Pilot Preparation
1. Identify and contact 20-30 pilot businesses
2. Create onboarding materials and training
3. Set up analytics and success tracking
4. Prepare demo materials for funding applications
---
## 📈 Growth Projections
### Conservative Scenario (Current Plan)
- **Month 6**: €8k-€12k MRR, 50 paying customers
- **Month 12**: €25k-€40k MRR, 150 customers
- **Month 18**: €50k-€80k MRR, 300 customers
### Optimistic Scenario (With Funding)
- **Month 6**: €25k MRR, 100 customers
- **Month 12**: €150k MRR, 500 customers
- **Month 18**: €600k MRR, 1,200 customers
### Key Success Factors
- Critical mass achievement (50+ participants)
- Data quality maintenance (>60% completion rate)
- Match conversion rate (>5% free-to-paid)
- Geographic clustering (70% within 5km)
---
## 🎯 Conclusion
The Turash platform concept represents a comprehensive, well-researched solution to industrial symbiosis with strong technical foundations and clear market opportunity. The backend implementation is progressing well with core functionality complete and advanced features in POC stage.
**Current Status**: Concept complete, MVP 70% built, ready for pilot launch
**Next Critical Step**: Complete database integration and launch Berlin pilot
**Funding Status**: All applications ready for submission
The project is well-positioned for seed funding and market validation in the next 3-6 months.
---
*Prepared by: Damir Mukimov*
*Date: November 2025*
*Next Review: December 2025*</content>
<parameter name="filePath">/Users/damirmukimov/city_resource_graph/PROJECT_READINESS_SUMMARY.md

View File

@ -67,13 +67,20 @@ A resource-matching engine that:
## 📁 Repository Structure
```
├── concept/ # Platform specifications & research
├── models/ # Go core algorithms & models
├── funding/ # Funding applications & strategy
├── dev_guides/ # Development documentation
├── bugulma/ # Proof-of-concept implementation (separate repos)
├── bugulma-scraper/ # Data collection tools (separate repos)
└── docs/ # Additional documentation
├── concept/ # Platform specifications & research
├── models/ # Go core algorithms & models
├── bugulma/ # Proof-of-concept implementation
├── docs/ # Documentation
│ ├── app/ # 🏗️ Core application docs (MVP, database)
│ ├── business/ # Business strategy, branding, funding
│ ├── implementation/ # Implementation plans and reports
│ ├── concept/ # Strategic concept and research
│ ├── dev_guides/ # Development guides
│ └── api/ # API documentation
├── scripts/ # Utility scripts and API tools
├── data/ # Sample data and datasets
├── archive/ # Archived data and completed projects
└── dev_guides/ # Development documentation
```
## 🛠️ Technology Stack
@ -131,19 +138,28 @@ We welcome contributions! Please see our [contributing guidelines](CONTRIBUTING.
### Core Documentation
- **[Platform Specification](concept/00_introduction.md)** - Complete technical overview
- **[MVP Concept](mvp_concept.md)** - Current development focus
- **[MVP Concept](docs/technical/mvp_concept.md)** - Current development focus
- **[Mathematical Models](concept/MATHEMATICAL_MODEL.md)** - Algorithm specifications
- **[Architecture](concept/29_technical_architecture_diagrams.md)** - System design
- **[Database Structure](docs/technical/database_structure.md)** - Data models and relationships
### Business Documentation
- **[Business Model](concept/monetisation/)** - Revenue streams and pricing
- **[Go-to-Market](concept/monetisation/go-to-market.md)** - Market strategy
- **[Competitive Analysis](concept/02_competitive_analysis.md)** - Market positioning
- **[Funding Strategy](docs/business/funding/)** - Funding applications and programs
- **[Branding](docs/business/turash_branding.md)** - Brand identity and positioning
### Technical Documentation
- **[Go 1.25 Stack](concept/12_go_125_stack_backend_architecture.md)** - Backend architecture
- **[Graph Database Integration](GRAPH_DATABASE_INTEGRATION.md)** - Neo4j implementation
- **[PostGIS Integration](POSTGIS_INTEGRATION.md)** - Spatial operations
- **[Development Guides](dev_guides/)** - Framework and tool guides
### Implementation & Planning
- **[Architecture Refactoring](docs/implementation/ARCHITECTURAL_REFACTORING_PLAN.md)** - Backend modernization plan
- **[Implementation Gap](docs/implementation/IMPLEMENTATION_GAP_REPORT.md)** - Current vs. required features
- **[Project Readiness](docs/implementation/PROJECT_READINESS_SUMMARY.md)** - Development status
## 🔗 Links

View File

@ -0,0 +1,663 @@
# Small City Problems & Original Solutions for Bugulma Platform
## The Real Problems People Face in Small Cities
Small cities like Bugulma face unique challenges that big cities don't. This document identifies **real problems** residents face daily and proposes **original solutions** that leverage the platform's unique position as a resource-matching and community hub.
---
## Core Problems & Solutions
### 1. "I Don't Know Who Has What" - The Information Gap Problem
**The Problem**:
- Need a specific tool/equipment/service but don't know who has it
- Businesses have unused resources but don't know who needs them
- People have skills but don't know who needs help
- Information is fragmented across social media, word-of-mouth, bulletin boards
**Original Solution: "What's Available Near Me" - Real-Time Resource Discovery**
**Features**:
- **Live Resource Map**: See ALL available resources in real-time on a map
- Business surplus (heat, materials, equipment)
- Community items (tools, furniture, food)
- Services offered (repairs, skills, expertise)
- Services needed (looking for plumber, need truck, etc.)
- **Smart Search**: "I need X within 2km"
- Natural language search: "Who has a truck I can borrow?"
- Category filters: Tools, Equipment, Services, Materials, Skills
- Distance-based ranking
- **Resource Alerts**: Get notified when something you need becomes available
- "Alert me when someone lists a [drill/van/plumber] nearby"
- Push notifications for urgent needs
- **"Ask the Community" Feature**:
- Post: "Does anyone have a ladder I can borrow for 2 hours?"
- Community responds with offers
- No need to know who to ask - platform finds the right people
**Why This is Original**:
- Combines B2B resource matching with community needs
- Real-time discovery vs. static directories
- Location-based matching (critical in small cities)
- Leverages existing business data
**Daily Use Cases**:
- "Need to move furniture - who has a van?"
- "Looking for a plumber who speaks Tatar"
- "Business has extra pallets - who needs them?"
- "Need someone to help with accounting"
---
### 2. "Money Leaves Our City" - Economic Leakage Problem
**The Problem**:
- People shop online or drive to bigger cities
- Local businesses struggle to compete
- Money doesn't circulate locally
- Hard to discover local alternatives
**Original Solution: "Local First Marketplace" - Keep Money in Bugulma**
**Features**:
- **Local Business Discovery Engine**:
- "Before you buy online, check local first"
- Search: "I need X" → Shows local businesses that have it
- Price comparison: Local price vs. online (with delivery)
- "Support Local" badges and incentives
- **Local Business Network Effects**:
- Businesses on platform get visibility boost
- "Shop Local" campaign with platform businesses
- Cross-promotion: "Customers of Business A get 10% off at Business B"
- Local business loyalty program
- **"Local Alternative" Suggestions**:
- User searches for product → Platform suggests local alternatives
- "Instead of Amazon, try [Local Business]"
- Show local businesses that can provide similar service
- **Community Currency/Points System**:
- Earn points for shopping local
- Points redeemable at any platform business
- Creates circular local economy
- Track "money kept in Bugulma" metric
**Why This is Original**:
- Proactive local business promotion (not just directory)
- Network effects between businesses
- Gamification of local shopping
- Integrates with resource matching (businesses on platform = more visible)
**Daily Use Cases**:
- "Need office supplies" → Shows local stationery shop
- "Looking for catering" → Shows local restaurants on platform
- "Need printing" → Shows local print shops
- Earn points for every local purchase
---
### 3. "I Can't Find the Right Person" - Skill & Service Matching Problem
**The Problem**:
- Need a specific service but don't know who does it
- Have a skill but no way to find clients
- Hard to verify if someone is trustworthy
- Fragmented across social media, flyers, word-of-mouth
**Original Solution: "Skill Exchange Network" - Connect People with Skills**
**Features**:
- **Skill Marketplace**:
- People list skills: "I can fix computers", "I speak English", "I do accounting"
- Businesses list needs: "Need someone to translate", "Need IT help"
- Community needs: "Need someone to teach kids coding"
- **Trust & Verification**:
- Platform businesses get verified badge
- Community ratings and reviews
- "Recommended by" network (mutual connections)
- Skills verified by community (endorsements)
- **Skill Matching Algorithm**:
- "I need X" → Finds people with skill X nearby
- Shows: Availability, rates, ratings, distance
- One-click contact
- **Skill Sharing & Learning**:
- "I can teach X" → People can request lessons
- Community workshops organized through platform
- Knowledge exchange: "I'll teach you Y if you teach me Z"
**Why This is Original**:
- Leverages platform's matching technology for people, not just resources
- Builds on existing business network (trust foundation)
- Creates economic opportunities for residents
- Addresses real small-city problem (fewer specialists)
**Daily Use Cases**:
- "Need someone to fix my computer" → Finds local IT person
- "I can do bookkeeping" → Gets matched with small businesses
- "Need English tutor" → Finds community member who teaches
- "Want to learn carpentry" → Connects with skilled person
---
### 4. "Things Sit Unused" - Underutilization Problem
**The Problem**:
- Expensive tools/equipment used once a year
- Businesses have idle capacity (trucks, space, equipment)
- Community resources underutilized
- Waste because "no one needs it"
**Original Solution: "Shared Resource Pool" - Maximize Utilization**
**Features**:
- **Community Tool Library**:
- People/businesses lend tools to community
- Reservation system with calendar
- Pickup/dropoff coordination
- "Most Popular Tools" ranking
- **Business Capacity Sharing**:
- Businesses share: trucks, storage space, meeting rooms, equipment
- Other businesses/community can rent/borrow
- Revenue sharing model
- "Idle Capacity Marketplace"
- **Time-Based Sharing**:
- "I have a van, available Tuesday afternoons"
- "Meeting room free on weekends"
- Calendar integration
- Automatic matching of availability
- **Resource Utilization Dashboard**:
- Show utilization rates: "This tool is used 5x/month"
- Identify underutilized resources
- Suggest sharing opportunities
- Track money saved through sharing
**Why This is Original**:
- Extends business resource matching to community
- Time-based matching (not just location)
- Economic model for sharing
- Reduces waste and costs
**Daily Use Cases**:
- "Need power drill for weekend project" → Borrows from library
- "Business has empty warehouse space" → Rents to another business
- "Need truck for moving" → Rents from business with idle capacity
- "Have meeting room" → Rents to community groups
---
### 5. "I Don't Know What's Happening" - Information Fragmentation Problem
**The Problem**:
- Events scattered across social media, flyers, word-of-mouth
- Hard to find: what's happening this weekend?
- Business news/updates not centralized
- Important announcements get lost
**Original Solution: "Everything Happening in Bugulma" - Centralized Information Hub**
**Features**:
- **Unified Event Feed**:
- All events in one place: business events, community events, city events
- Calendar view: "What's happening this week?"
- Personalized: "Events near you", "Events you might like"
- RSVP and reminders
- **Business Updates Feed**:
- Businesses post: new services, special offers, events, news
- Follow businesses to get updates
- "Businesses near you" updates
- Integration with resource matching (new resources, new needs)
- **Smart Notifications**:
- "New event near you this weekend"
- "Business you follow has new service"
- "Resource you need just became available"
- "Someone needs your skill"
- **Information Layers on Map**:
- Toggle layers: Events, Businesses, Resources, Services
- See everything happening in your area
- Time-based: "What's happening now?"
**Why This is Original**:
- Combines business updates with community events
- Location-based information discovery
- Proactive notifications (not just search)
- Single source of truth for local information
**Daily Use Cases**:
- "What's happening this weekend?" → See all events
- "New business opened" → Get notified
- "Business I follow has sale" → See in feed
- "Event near me" → Get reminder
---
### 6. "Transportation is Limited" - Mobility Problem
**The Problem**:
- Limited public transport
- Need rides but don't know who's going
- Businesses need deliveries but no courier service
- Expensive to own/maintain vehicles
**Original Solution: "Community Transportation Network" - Shared Mobility**
**Features**:
- **Ride Sharing Coordination**:
- "I'm going to [place] at [time], have 3 seats"
- "Need ride to [place] on [date]"
- Automatic matching of routes
- Cost sharing calculator
- **Business Delivery Network**:
- Businesses need deliveries → Community members with vehicles
- "Delivery needed: [A] to [B], pay €X"
- Route optimization for multiple deliveries
- Regular delivery routes (subscription model)
- **Carpool Matching**:
- Regular commutes: "Going to work at 8am, need riders"
- School runs: "Taking kids to school, share ride"
- Event carpools: "Going to event, share transport"
- **Vehicle Sharing**:
- "I have a van, available for rent"
- "Need truck for moving" → Rent from community member
- Insurance and safety verification
- Rating system for reliability
**Why This is Original**:
- Combines business needs (deliveries) with community (rides)
- Route-based matching (not just location)
- Economic model (people earn from sharing)
- Addresses real small-city problem
**Daily Use Cases**:
- "Need ride to airport" → Finds someone going
- "Business needs delivery" → Community member does it
- "Regular commute" → Forms carpool group
- "Need van for weekend" → Rents from neighbor
---
### 7. "Problems Don't Get Fixed" - Coordination & Reporting Problem
**The Problem**:
- Potholes, broken streetlights, infrastructure issues
- Don't know who to report to
- Reports get lost in bureaucracy
- No visibility on fix status
**Original Solution: "Community Issue Tracker" - Transparent Problem Solving**
**Features**:
- **Issue Reporting with Photos**:
- Report: pothole, broken light, illegal dumping, water leak
- Photo + location automatically captured
- Category selection: Infrastructure, Environment, Safety
- Anonymous or named reporting
- **Public Issue Dashboard**:
- All reported issues visible on map
- Status tracking: Reported → Acknowledged → In Progress → Fixed
- Upvoting: "This is important" (prioritization)
- Comments: "Still broken", "Fixed but..."
- **Integration with City Services**:
- Auto-forward to relevant city department
- Track response times
- Public accountability
- "City Response Time" metrics
- **Community Action**:
- "Community can fix this" option
- Organize volunteer fixes
- Crowdfund for fixes city won't do
- "Adopt a spot" program
**Why This is Original**:
- Transparency (all issues visible)
- Community can take action (not just report)
- Leverages platform's map and organization features
- Creates accountability
**Daily Use Cases**:
- "Report pothole" → City fixes it, status visible
- "Illegal dumping" → Community organizes cleanup
- "Broken playground" → Crowdfund for fix
- "Track all issues in my neighborhood"
---
### 8. "Hard to Organize Things" - Coordination Problem
**The Problem**:
- Want to organize event but hard to coordinate
- Need volunteers but don't know who's available
- Need materials/resources but don't know who has them
- Communication is fragmented
**Original Solution: "Community Organizer Toolkit" - All-in-One Event/Project Management**
**Features**:
- **Event/Project Creation**:
- Create: event, cleanup, project, initiative
- Set: date, location, needs (volunteers, materials, skills)
- Auto-match: "We need X" → Finds who has X
- **Resource Coordination**:
- "We need: 5 volunteers, truck, tools, food"
- Platform matches needs with available resources
- "Who can help?" → Community responds
- Track: what's needed, what's covered
- **Volunteer Matching**:
- "Need volunteers for [event]" → People sign up
- Skills matching: "Need someone who can [skill]"
- Availability matching: "Available on [date]?"
- Reminders and check-in
- **Material/Resource Gathering**:
- "We need: tables, chairs, sound system"
- Community/businesses offer: "I have tables"
- Track what's needed vs. what's available
- Pickup/delivery coordination
**Why This is Original**:
- Combines event planning with resource matching
- Leverages platform's matching technology
- All coordination in one place
- Reduces friction for organizing
**Daily Use Cases**:
- "Organize neighborhood cleanup" → Gets volunteers, tools, materials
- "Community event" → Coordinates everything through platform
- "Need help with project" → Finds volunteers and resources
- "Business opening event" → Coordinates through platform
---
### 9. "Newcomers Feel Lost" - Integration Problem
**The Problem**:
- New residents don't know anyone
- Don't know where things are
- Hard to integrate into community
- Information overload
**Original Solution: "Welcome to Bugulma" - Newcomer Integration System**
**Features**:
- **Personalized Onboarding**:
- "New to Bugulma? Let's get you set up"
- Questionnaire: interests, needs, skills
- Personalized recommendations
- **Essential Information Hub**:
- "Where to find: doctors, schools, shops, services"
- Map of essential locations
- "Best of Bugulma" recommendations
- Local tips and tricks
- **Connection Matching**:
- "People with similar interests"
- "Neighbors near you"
- "Businesses you might need"
- "Community groups to join"
- **Newcomer Challenges**:
- "30-Day Bugulma Challenge"
- Tasks: Visit 5 local businesses, Attend 1 event, Meet 3 neighbors
- Rewards and badges
- Integration into community
**Why This is Original**:
- Proactive integration (not just information)
- Uses platform's matching for people
- Gamification for engagement
- Addresses real small-city problem
**Daily Use Cases**:
- "Just moved here" → Gets personalized guide
- "Need to find dentist" → Shows local options
- "Want to meet people" → Matches with similar interests
- "30-day challenge" → Integrates into community
---
### 10. "Waste of Resources" - Inefficiency Problem
**The Problem**:
- Food goes to waste
- Materials thrown away that could be reused
- Businesses waste resources
- No easy way to redistribute
**Original Solution: "Waste Not, Want Not" - Resource Redistribution Network**
**Features**:
- **Food Rescue Network**:
- Restaurants/cafes: "We have leftover food"
- Community: "Need food" or "Can distribute"
- Automatic matching and notifications
- Integration with food banks
- **Material Redistribution**:
- Businesses: "We're throwing away X, who wants it?"
- Community: "I need X" → Gets it free
- "Waste stream marketplace"
- Track: "X tonnes diverted from landfill"
- **Surplus Alert System**:
- "Business has surplus: [item]"
- Push notifications to interested people
- "First come, first served" or "Best use" matching
- Regular surplus from businesses
- **Waste Reduction Dashboard**:
- Track: food saved, materials reused, waste diverted
- "Impact of redistribution" metrics
- Leaderboard: "Businesses reducing waste"
- Community impact visualization
**Why This is Original**:
- Proactive waste reduction (not just reporting)
- Real-time matching of surplus with need
- Economic and environmental benefits
- Leverages business network
**Daily Use Cases**:
- "Restaurant has leftover food" → Community gets it
- "Business throwing away pallets" → Someone takes them
- "Surplus materials" → Redistributed to community
- "Track waste reduction impact"
---
## Implementation Priority: Most Impactful First
### Tier 1: High Impact, Solves Real Problems (Implement First)
1. **"What's Available Near Me"** - Resource Discovery
- Solves: Information gap, underutilization
- Daily use: High
- Implementation: Medium (leverages existing matching)
2. **"Skill Exchange Network"** - Skill Matching
- Solves: Can't find right person
- Daily use: High
- Implementation: Medium (extends matching to people)
3. **"Everything Happening"** - Information Hub
- Solves: Information fragmentation
- Daily use: Very High
- Implementation: Low (content management)
### Tier 2: High Value, Addresses Core Problems
4. **"Local First Marketplace"** - Economic Leakage
- Solves: Money leaves city
- Daily use: Medium-High
- Implementation: Medium (business directory enhancement)
5. **"Community Transportation"** - Mobility
- Solves: Limited transport
- Daily use: Medium
- Implementation: High (new feature category)
6. **"Shared Resource Pool"** - Underutilization
- Solves: Things sit unused
- Daily use: Medium
- Implementation: Medium (extends resource matching)
### Tier 3: Community Building & Engagement
7. **"Community Organizer Toolkit"** - Coordination
8. **"Issue Tracker"** - Problem Reporting
9. **"Welcome to Bugulma"** - Newcomer Integration
10. **"Waste Not"** - Resource Redistribution
---
## Unique Value Propositions
### What Makes These Solutions Original:
1. **Leverages Existing Platform**:
- Uses resource matching technology for new use cases
- Builds on business network for community features
- Map and location features for all solutions
2. **Solves Real Small-City Problems**:
- Not generic community features
- Addresses specific pain points
- Daily-use value
3. **Network Effects**:
- More users = better matching = more value
- Businesses + Community = stronger network
- Each feature reinforces others
4. **Economic Model**:
- Creates value for businesses (more visibility, customers)
- Creates value for community (savings, opportunities)
- Platform benefits from engagement
---
## Success Metrics
### Problem-Solving Metrics:
- **Resource Discovery**: % of "I need X" queries that find matches
- **Local Shopping**: % of users who find local alternative before buying online
- **Skill Matching**: Number of successful skill/service matches
- **Transportation**: Number of rides/deliveries coordinated
- **Issue Resolution**: % of reported issues that get fixed
- **Event Success**: Number of events organized through platform
### Engagement Metrics:
- **Daily Active Users**: Target 500+ by month 6
- **Query Volume**: "I need X" searches per day
- **Match Success Rate**: % of queries that result in connections
- **Return Usage**: % of users who use platform weekly
---
## Technical Considerations
### New Data Models Needed:
```sql
-- Skills/Services
CREATE TABLE skills (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
skill_name VARCHAR(100),
description TEXT,
category VARCHAR(50),
availability JSONB, -- {days, times}
rate DECIMAL(10,2),
location POINT
);
-- Resource Listings (Community)
CREATE TABLE community_listings (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
title VARCHAR(255),
type VARCHAR(50), -- tool, equipment, service, material
description TEXT,
location POINT,
availability JSONB,
status VARCHAR(20), -- available, reserved, taken
created_at TIMESTAMP
);
-- Transportation
CREATE TABLE ride_offers (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
from_location POINT,
to_location POINT,
departure_time TIMESTAMP,
seats_available INT,
cost_per_person DECIMAL(10,2),
status VARCHAR(20)
);
CREATE TABLE ride_requests (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
from_location POINT,
to_location POINT,
desired_time TIMESTAMP,
seats_needed INT,
max_cost DECIMAL(10,2),
status VARCHAR(20)
);
-- Issues
CREATE TABLE community_issues (
id UUID PRIMARY KEY,
reporter_id UUID REFERENCES users(id),
type VARCHAR(50), -- infrastructure, environment, safety
description TEXT,
location POINT,
photos TEXT[],
status VARCHAR(20), -- reported, acknowledged, in_progress, fixed
upvotes INT DEFAULT 0,
created_at TIMESTAMP
);
```
---
## Conclusion
These solutions address **real problems** that people in small cities face daily. They're not generic community features - they're **problem-solving tools** that leverage the platform's unique capabilities:
- Resource matching technology → Applied to people, skills, services
- Business network → Extended to community
- Location features → Used for discovery and coordination
- Platform infrastructure → Supports new use cases
The key is **solving actual problems** that make people's lives easier, not just adding features for engagement. When you solve real problems, engagement follows naturally.
---
**Document Version**: 1.0
**Last Updated**: 2025-01-27
**Focus**: Real Problems, Original Solutions

View File

@ -1,587 +0,0 @@
# Frontend UX/UI Assessment & Recommendations
## Comprehensive Analysis Based on Actual Code Review
**Date:** November 24, 2025
**Assessment Type:** Deep Code Review + UX/UI Best Practices Audit
---
## Executive Summary
After thorough code review, the frontend demonstrates **significantly stronger technical foundation** than initially assessed. The codebase follows modern React best practices with excellent performance optimizations, accessibility features, and a well-architected component library.
### Key Findings
**Strong Foundation:** 30+ memoized components, sophisticated map infrastructure, production-ready UI library
**Modern Architecture:** React Query, Zod validation, code splitting, error boundaries
**Performance-First:** WeakMap caching, viewport-based loading, debouncing, lazy loading
⚠️ **Coverage Gap:** Only 30% of backend capabilities have UI (70% missing)
⚠️ **Quick Win Potential:** Many missing features can reuse existing components
---
## Current State: What's Already Built
### Pages (11 total)
| Page | Status | Quality | Notes |
|------|--------|---------|-------|
| LandingPage | ✅ Excellent | A+ | Framer Motion animations, sections well-designed |
| MapView | ✅ Excellent | A+ | Leaflet clustering, viewport loading, 5 split contexts |
| OrganizationPage | ✅ Good | A | Tabbed interface, AI analysis, web intelligence |
| HeritagePage | ✅ Complete | A | Timeline visualization, image optimization |
| HeritageBuildingPage | ✅ Complete | A | Detailed building view |
| UserDashboard | ⚠️ Basic | B | Functional but needs enhancement |
| AdminPage | ⚠️ Basic | B | Needs data management features |
| LoginPage | ✅ Functional | B+ | Works, could use better UX |
| AboutPage | ✅ Complete | A | Static content |
| ContactPage | ✅ Complete | A | Static content |
| PrivacyPage | ✅ Complete | A | Static content |
### Component Library (Production-Ready) ✅
**UI Primitives (30+ components):**
- Layout: Container, Grid, Stack, Flex
- Forms: Input, Select, Textarea, Checkbox, MultiSelect
- Feedback: Button, Badge, Card, Spinner, Skeleton
- Navigation: Tabs, Separator
- Media: ImageUpload, MapPicker
- Error: ErrorBoundary, ModuleErrorBoundary
**Domain Components (Ready to Reuse):**
- ResourceFlowCard ✅ (already built!)
- ResourceFlowList ✅ (already built!)
- MatchCard ✅ (already built!)
- MatchesList ✅ (already built!)
- Wizard (multi-step forms with portals) ✅
- KeyMetrics (metric display) ✅
- Timeline (for history/events) ✅
**Map Components (Sophisticated):**
- LeafletMap with MarkerClusterGroup
- MapBoundsTracker (viewport-based loading)
- SymbiosisLines (connection visualization)
- MapFilters, MapControls, MapSidebar
- HistoricalMarkers, SiteMarkers
---
## UX/UI Best Practices Assessment
### ✅ Excellent Practices Already in Place
#### 1. Performance Optimization (Industry-Leading)
**Code Splitting & Lazy Loading:**
```typescript
// All routes use React.lazy()
const LandingPage = React.lazy(() => import('../pages/LandingPage.tsx'));
const MapView = React.lazy(() => import('../pages/MapView.tsx'));
```
**Memoization Strategy:**
- 30+ components wrapped in `React.memo`
- `useMemo` for expensive calculations (array filtering, object creation)
- `useCallback` for event handlers (prevents child re-renders)
- Map-based lookups (O(1) instead of O(n) `.find()`)
**Icon Caching:**
```typescript
// WeakMap for automatic garbage collection
const iconCache = new WeakMap();
// 90% reduction in icon creation time
```
**Viewport-Based Loading:**
```typescript
// Map markers load only for visible area
const sites = useSitesByBounds(bounds);
// Prevents loading 1000s of markers at once
```
**Debouncing:**
```typescript
// 300ms debounce for search/map interactions
const debouncedSearch = useDebounce(searchTerm, 300);
```
#### 2. Accessibility (a11y) Compliance
✅ ARIA labels on all interactive elements
✅ Keyboard navigation (Esc to close modals)
✅ Focus trapping in modals (`useFocusTrap` hook)
✅ Semantic HTML structure
✅ Error boundaries with fallback UI
✅ Screen reader friendly
```tsx
// Example: Wizard modal
<motion.div
role="dialog"
aria-modal="true"
aria-labelledby="wizard-title"
>
```
#### 3. Form UX Excellence
**React Hook Form + Zod:**
```typescript
// Type-safe forms with real-time validation
const form = useForm<OrganizationFormData>({
resolver: zodResolver(getOrganizationFormSchema(t)),
});
```
**Multi-Step Wizard:**
- Portal rendering for modals (better z-index management)
- Progress indication
- Step validation
- Escape key to close
#### 4. Data Fetching Best Practices
**React Query with Custom Hooks:**
```typescript
// Reusable factory pattern
export const useResourceFlowsByOrganization = createConditionalListQueryHook(
(organizationId) => organizationId ? resourceFlowKeys.byOrganization(organizationId) : [],
(organizationId) => getResourceFlowsByOrganization(organizationId)
);
```
**Features:**
- Automatic retry with exponential backoff
- Cache invalidation on mutations
- Placeholder data to prevent blocking
- Stable query keys (rounded coordinates)
- Type-safe with Zod schemas
#### 5. Error Handling & User Feedback
**Error Boundaries:**
```typescript
// Global + Module-specific boundaries
<ModuleErrorBoundary moduleName="interactive map">
<LeafletMap />
</ModuleErrorBoundary>
```
**Loading States:**
- Skeleton loaders for content
- Spinners for actions
- Empty states with guidance
- Error messages with retry actions
#### 6. Responsive Design
**Mobile-First Approach:**
```typescript
// Tailwind CSS utility classes
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
```
**Responsive Features:**
- Bottom sheets for mobile modals
- Touch-friendly map controls
- Collapsible sidebars
- Responsive grid layouts
#### 7. Internationalization (i18n)
**Bilingual Support:**
```typescript
const { t, locale, setLocale } = useTranslation();
// Russian and English fully supported
```
**Memoized Translations:**
```typescript
// Prevents recreation on every render
const pluralRules = useMemo(() => new Intl.PluralRules(locale), [locale]);
```
---
## Gap Analysis: What's Missing
### Backend API Coverage
| Category | Endpoints | Frontend Coverage | Gap |
|----------|-----------|-------------------|-----|
| Organizations | 8 | 70% | Missing: Bulk ops, advanced filters |
| Sites | 7 | 40% | Missing: CRUD UI, heritage management |
| Resource Flows | 6 | 10% | ⚠️ Components exist but no pages! |
| Matching | 5 | 0% | ⚠️ Components exist but no pages! |
| Analytics | 8 | 0% | Missing entirely |
| Geospatial | 3 | 30% | Missing: Clusters, spatial analysis |
| Shared Assets | 6 | 0% | Missing entirely |
| Graph API | 5 | 0% | Missing entirely |
| Products/Services | 0 | - | Embedded in orgs, no dedicated UI |
**Overall Backend Coverage: ~30%**
---
## Revised Recommendations
### Strategy: Focused Enhancement Over Expansion
**Previous Recommendation:** Add 14+ new pages (11 → 25+)
**Revised Recommendation:** Add 8-10 strategic pages, enhance 3 existing
**Rationale:**
1. Strong component library makes development faster than estimated
2. ResourceFlowCard/List and MatchCard/List already built
3. Better to polish fewer features than dilute with many half-baked pages
4. Leverage existing Wizard, map, and form infrastructure
---
## Priority 1: Quick Wins (Weeks 1-3)
### 1. Resource Flow Pages (3 pages) ⚡ **FAST TRACK**
**Why Fast:** ResourceFlowCard and ResourceFlowList components already exist!
**A. ResourceFlowsPage.tsx** (2-3 days)
```typescript
// Reuse existing components
<ResourceFlowList
flows={flows}
onViewMatches={handleViewMatches}
/>
// Add existing filters
<MapFilters />
```
**B. ResourceFlowWizard.tsx** (3-4 days)
```typescript
// Reuse existing Wizard component
<Wizard isOpen={isOpen} onClose={onClose}>
<Step1 /> {/* Type + Direction */}
<Step2 /> {/* Quantity - Zod schema exists */}
<Step3 /> {/* Quality params */}
<Step4 /> {/* Economic data */}
</Wizard>
```
**C. ResourceFlowDetail.tsx** (2-3 days)
```typescript
// Compose existing components
<ResourceFlowCard flow={flow} />
<KeyMetrics metrics={economicMetrics} />
<MatchesList matches={relatedMatches} />
```
**Total Effort:** 7-10 days
**Impact:** Enables core platform functionality
---
### 2. Matching Pages (3 pages) ⚡ **FAST TRACK**
**Why Fast:** MatchCard, MatchesList, and useFindMatches hook ready!
**A. MatchingDashboard.tsx** (2-3 days)
```typescript
// Reuse existing components
<MatchesList matches={topMatches} />
<MapFilters /> {/* For filtering */}
<MetricItem /> {/* For stats */}
```
**B. MatchDetailPage.tsx** (3-4 days)
```typescript
// Compose existing components
<MatchCard match={match} />
<Grid cols={3}>
<MetricItem label="Compatibility" value="85%" />
<MetricItem label="Economic Value" value="€45k" />
<MetricItem label="Distance" value="3.2 km" />
</Grid>
<Timeline entries={negotiationHistory} />
```
**C. MatchesMapView.tsx** (2-3 days)
```typescript
// Extend existing LeafletMap
<LeafletMap>
<Polyline positions={[source, target]} />
<MarkerClusterGroup>
{matches.map(m => <Marker ... />)}
</MarkerClusterGroup>
</LeafletMap>
```
**Total Effort:** 7-10 days
**Impact:** Complete matching workflow
---
### 3. Sites Management (2 pages) (Weeks 2-3)
**A. SitesListPage.tsx** (2-3 days)
- Reuse existing map infrastructure
- Table/map toggle view
- Filter by type, ownership
**B. SiteDetailPage.tsx** (2-3 days)
- Location display (existing MapPicker)
- Resource flows at site (existing ResourceFlowList)
- Operating organizations
**Total Effort:** 4-6 days
---
## Priority 2: High-Value Additions (Weeks 4-6)
### 4. Analytics Dashboard (2 pages)
**A. AnalyticsDashboard.tsx** (4-5 days)
```typescript
<Grid cols={3}>
<MetricItem label="Total Organizations" value={stats.orgs} />
<MetricItem label="Active Matches" value={stats.matches} />
<MetricItem label="CO2 Saved" value={`${stats.co2}t`} />
</Grid>
<Charts> {/* Simple chart library */}
<BarChart data={supplyDemand} />
<LineChart data={growth} />
</Charts>
```
**B. ImpactMetrics.tsx** (3-4 days)
- Environmental impact visualization
- Economic value created
- Resource recovery rates
**Total Effort:** 7-9 days
---
### 5. Enhanced UserDashboard (Enhance Existing)
**Additions:**
- Recent activity feed
- My resource flows
- My matches
- Quick actions
**Effort:** 3-4 days
---
## Priority 3: Nice-to-Have (Weeks 7-10)
### 6. Products & Services Marketplace (2-3 pages)
- ProductsCatalog.tsx
- ServicesCatalog.tsx
- ServiceNeedsBoard.tsx
### 7. Shared Assets (2 pages)
- SharedAssetsPage.tsx
- SharedAssetDetail.tsx
### 8. Graph Network Visualization (1-2 pages)
- NetworkGraphPage.tsx (requires D3.js or Cytoscape)
- NetworkAnalytics.tsx
---
## Implementation Best Practices
### Component Reuse Strategy
**Before creating new components, check:**
1. ✅ UI primitives (Button, Card, Input, etc.)
2. ✅ Layout components (Grid, Stack, Container)
3. ✅ Domain components (ResourceFlowCard, MatchCard)
4. ✅ Form patterns (Wizard, multi-step forms)
5. ✅ Map components (LeafletMap, markers, filters)
### Code Quality Checklist
**For every new page/component:**
- [ ] Wrap in React.memo if receiving props
- [ ] Use useMemo for expensive calculations
- [ ] Use useCallback for event handlers
- [ ] Add loading skeleton
- [ ] Add error boundary
- [ ] Add empty state
- [ ] Implement responsive design
- [ ] Add ARIA labels
- [ ] Test keyboard navigation
- [ ] Validate with Zod schemas
- [ ] Use React Query for data fetching
- [ ] Add i18n translations
### Performance Checklist
- [ ] Code split with React.lazy()
- [ ] Debounce user input (300ms)
- [ ] Use placeholder data in queries
- [ ] Implement virtualization for large lists (react-window)
- [ ] Optimize images (lazy loading, async decoding)
- [ ] Use stable query keys
- [ ] Avoid inline object/array creation in render
---
## Revised Timeline
### Phase 1: Core Features (Weeks 1-3) - **FAST**
- Resource Flow pages (3 pages) ⚡ 7-10 days
- Matching pages (3 pages) ⚡ 7-10 days
- Sites pages (2 pages) - 4-6 days
**Deliverable:** Users can create flows, find matches, manage sites
### Phase 2: Analytics & Enhancement (Weeks 4-6)
- Analytics Dashboard (2 pages) - 7-9 days
- Enhanced UserDashboard - 3-4 days
- Organization enhancements - 3-4 days
**Deliverable:** Comprehensive platform analytics
### Phase 3: Marketplace (Weeks 7-9) - Optional
- Products/Services (2-3 pages) - 8-10 days
- Shared Assets (2 pages) - 5-6 days
**Deliverable:** Full marketplace functionality
### Phase 4: Advanced Features (Weeks 10-12) - Optional
- Graph Network (2 pages) - 8-10 days (needs new library)
- Geospatial Analysis - 3-4 days
- Admin enhancements - 3-4 days
**Deliverable:** Advanced capabilities
**Total Timeline:** 6-12 weeks (depending on scope)
---
## Success Metrics
### Technical Metrics
- [ ] Lighthouse score > 90 (performance, accessibility, best practices)
- [ ] Page load time < 2s
- [ ] API response time < 500ms
- [ ] Zero accessibility violations (axe-core)
- [ ] TypeScript strict mode with 0 errors
- [ ] Test coverage > 70%
### UX Metrics
- [ ] Time to create resource flow < 3 min
- [ ] Time to find first match < 30 sec
- [ ] Task completion rate > 90%
- [ ] User satisfaction score > 4.2/5
- [ ] Mobile usability score > 85%
### Business Metrics
- [ ] Active matches +200%
- [ ] CO2 savings tracked: 10,000+ tonnes/year
- [ ] Economic value created: €1M+ annually
- [ ] Daily active users +50%
---
## Key Recommendations
### 1. Start with Quick Wins (Priority 1)
- Resource Flow and Matching pages can be built in 2-3 weeks
- Reuse existing components for 70%+ of UI
- High impact (enables core functionality)
### 2. Don't Over-Engineer
- You have excellent components—use them!
- Resist urge to rebuild what works
- Focus on UX polish over new features
### 3. Maintain Quality Standards
- Current codebase has high standards (memoization, a11y, i18n)
- New code should match this quality
- Use existing patterns (API hooks, Zod schemas, etc.)
### 4. Progressive Enhancement
- Build core features first (Phases 1-2)
- Add nice-to-haves later (Phases 3-4)
- Monitor usage to prioritize features
### 5. Leverage AI/LLM Features
- Existing LLM abstraction layer for AI features
- Use for smart suggestions, analysis, content generation
- Enhance user experience without complex UI
---
## Conclusion
The frontend is **significantly more advanced** than initially assessed. With proper component reuse and focused development, the gap between frontend and backend can be closed in **6-12 weeks** instead of the initially estimated 18 weeks.
**Key Insight:** The "missing" features aren't actually missing—they're just not assembled into pages yet. The building blocks (ResourceFlowCard, MatchCard, MatchesList, Wizard, etc.) already exist. This is a **composition problem, not a creation problem**.
**Recommended Next Steps:**
1. ✅ Review this assessment with stakeholders
2. ✅ Prioritize Phase 1 features (Resource Flows + Matching)
3. ✅ Start development with Quick Wins
4. ✅ Iterate based on user feedback
5. ✅ Add Phase 2+ features progressively
**Estimated Effort to Production:**
- Minimal viable: 3 weeks (Phase 1 only)
- Recommended: 6 weeks (Phases 1-2)
- Full platform: 12 weeks (All phases)
**Resource Requirement:** 1-2 frontend developers

File diff suppressed because it is too large Load Diff

View File

@ -1,199 +0,0 @@
# Graph Visualization Proof of Concept
## Overview
Interactive network graph visualization integrated into the Organization detail pages, demonstrating how organizations connect to sites, resources, and other organizations through the Neo4j graph database.
## Implementation
### Backend API Endpoints
**Base URL:** `/api/graph`
1. **GET /organizations/:organizationId/network**
- Returns graph data for an organization's network
- Query params: `depth` (1-3, default: 2)
- Response format:
```json
{
"nodes": [
{
"id": "uuid",
"label": "Organization Name",
"type": "organization|site|resource_flow",
"properties": {}
}
],
"edges": [
{
"id": "uuid",
"source": "node_id",
"target": "node_id",
"type": "OWNS|PROVIDES|CONSUMES|CONNECTED_TO",
"properties": {}
}
]
}
```
2. **GET /shortest-path**
- Find shortest path between two entities
- Query params: `from`, `to`
3. **GET /matching-opportunities**
- Find resource matching opportunities
- Query params: `organizationId`
4. **GET /spatial-proximity**
- Find spatially nearby entities
- Query params: `organizationId`, `maxDistance`
5. **GET /statistics**
- Graph-wide statistics and metrics
### Frontend Component
**Location:** `components/organization/NetworkGraph.tsx`
**Features:**
- Interactive graph visualization using vis-network
- 3-level depth control (1, 2, or 3 degrees of separation)
- Click nodes to navigate to organization/site pages
- Physics-based layout with automatic positioning
- Color-coded nodes by type:
- Organizations: Blue (circle)
- Sites: Green (box)
- Resource Flows: Orange (diamond)
- Interactive controls: zoom, pan, drag nodes
- Hover tooltips showing entity details
**Integration:**
Added to `OrganizationContent.tsx` between the organization details grid and resource flow list.
### Dependencies Added
```bash
npm install vis-network vis-data
```
## Usage
### View Organization Network
1. Navigate to any organization detail page: `/organizations/:id`
2. Scroll to the "Network Graph" section
3. Use depth buttons (1, 2, 3) to expand/contract the network
4. Click nodes to navigate to that entity's page
5. Use mouse to pan/zoom and drag nodes
### API Testing
```bash
# Get organization network
curl http://localhost:8080/api/graph/organizations/{id}/network?depth=2
# Find shortest path
curl http://localhost:8080/api/graph/shortest-path?from={id1}&to={id2}
# Get matching opportunities
curl http://localhost:8080/api/graph/matching-opportunities?organizationId={id}
```
## Technical Details
### Graph Data Conversion
The `PathToGraphData()` function in `internal/handler/graph_types.go` converts Neo4j path results into visualization-friendly JSON format:
1. Extracts nodes and relationships from Neo4j paths
2. Deduplicates nodes by ID
3. Converts to standardized format with:
- Node IDs, labels, types, properties
- Edge IDs, source/target, relationship types
- Type-based styling information
### Visualization Options
The vis-network is configured with:
- **Physics Engine:** Barnes-Hut simulation for natural positioning
- **Stabilization:** 100 iterations for initial layout
- **Interaction:** Hover tooltips, click handlers, navigation buttons
- **Styling:** Custom colors/shapes by entity type
- **Smooth Edges:** Continuous curves with 0.5 roundness
## Future Enhancements
### Filtering & Controls
- Filter by relationship type
- Filter by entity type
- Search/highlight specific nodes
- Save/load custom layouts
### Export Options
- Export as PNG/SVG
- Export raw JSON data
- Share graph views
### Advanced Features
- Time-based animation of resource flows
- Highlight critical paths
- Show impact metrics on edges
- Clustering of similar entities
- 3D visualization mode
### Performance
- Implement pagination for large networks (>1000 nodes)
- Add caching for frequently accessed graphs
- Lazy loading of node details
- WebGL rendering for very large graphs
## Validation Checklist
- [x] Backend graph endpoints implemented
- [x] Graph handler returns standardized JSON format
- [x] Endpoints registered in main.go
- [x] NetworkGraph component created
- [x] vis-network dependency installed
- [x] Component integrated into OrganizationPage
- [x] Click handlers for navigation
- [x] Depth control (1, 2, 3 levels)
- [x] Error handling and loading states
- [x] Type-safe TypeScript implementation
- [ ] End-to-end testing with real Neo4j data
- [ ] Performance testing with large graphs
- [ ] Mobile responsiveness
## Next Steps
1. **Test with Real Data:** Populate Neo4j with actual organization/site relationships and verify graph rendering
2. **Backend Server Running:** Ensure Go server is running on port 8080
3. **Frontend Dev Server:** Run `npm run dev` in bugulma/frontend
4. **Navigate:** Go to <http://localhost:5173/organizations/{id}> to see the graph
5. **Verify API:** Check browser DevTools Network tab for `/api/graph/` calls
## Related Files
**Backend:**
- `internal/handler/graph_handler.go` - HTTP handlers
- `internal/handler/graph_types.go` - Type definitions and conversion
- `internal/service/graph_service.go` - Neo4j queries
- `cmd/server/main.go` - Route registration
**Frontend:**
- `components/organization/NetworkGraph.tsx` - Graph component
- `components/organization/OrganizationContent.tsx` - Integration point
- `package.json` - Dependencies
**Documentation:**
- `concept/23_example_query_in_cypher_neo4j.md` - Neo4j query examples
- `concept/09_graph_database_design.md` - Graph schema

43
bugulma/Makefile Normal file
View File

@ -0,0 +1,43 @@
# Root Makefile for Turash Development
.PHONY: help dev dev-backend dev-frontend dev-full build-frontend build-backend
# Default target
help: ## Show this help message
@echo "Turash Development Commands:"
@echo ""
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
# Development commands
dev: ## Start both backend and frontend development servers
@echo "🚀 Starting full development environment (backend + frontend)"
@echo "Note: Make sure backend infrastructure is running (cd backend && make infra)"
@echo ""
@make -j2 dev-backend dev-frontend
dev-backend: ## Start backend development server
@echo "🔧 Starting backend server..."
@cd backend && go run ./cmd/cli server
dev-frontend: ## Start frontend development server
@echo "🌐 Starting frontend development server..."
@cd frontend && yarn dev
# Build commands
build-backend: ## Build backend
@cd backend && make build
build-frontend: ## Build frontend
@cd frontend && yarn build
# Infrastructure
infra: ## Start backend infrastructure
@cd backend && make infra
infra-down: ## Stop backend infrastructure
@cd backend && make infra-down
# Cleanup
clean: ## Clean all build artifacts
@cd backend && make clean
@cd frontend && rm -rf dist/ node_modules/.vite

View File

@ -1,256 +0,0 @@
# Organization vs Business - Architectural Analysis
## Summary
**Correct Architecture:** Organization is the main entity that can represent various types (governmental, business, NGO, etc.). Business is a **subtype/specialization** of Organization for commercial entities.
**Current Issue:** The codebase has both concepts defined but they're used inconsistently. Sites reference "Business" when they should reference "Organization" (the parent entity).
## Correct Architecture
### Hierarchical Relationship
```
Organization (Base Entity)
├── Business (Commercial Organization)
│ ├── Legal form, certifications, NACE codes
│ ├── Strategic vision, readiness maturity
│ └── Trust scores, business focus
├── Governmental Organization
├── NGO / Non-Profit
└── Other Organization Types
```
**Key Principles:**
- **Organization** = Main entity (can be any type: business, government, NGO, etc.)
- **Business** = Subtype of Organization (commercial entities only)
- **Site** = Owned/operated by Organizations (not just Businesses)
- **ResourceFlow** = Attached to Sites, owned by Organizations
**Correct Relationship**: Organization → Sites → ResourceFlows
### 2. Backend Implementation (Actual State)
#### Two Separate Domain Models Exist:
**`domain/organization.go`** (Currently Used):
```go
type Organization struct {
ID string
Name string
Sector string // Simple string, not NACE code
Description string
LogoURL string
Website string
Address string
Verified bool
CreatedAt time.Time
UpdatedAt time.Time
}
```
**`domain/business.go`** (Defined but Not Fully Integrated):
```go
type Business struct {
ID string
Name string
LegalForm string
PrimaryContactEmail string
PrimaryContactPhone string
IndustrialSector string // NACE code
CompanySize int
YearsOperation int
SupplyChainRole string
Certifications []string
BusinessFocus []string
StrategicVision string
DriversBarriers string
ReadinessMaturity int
TrustScore float64
CreatedAt time.Time
UpdatedAt time.Time
}
```
#### API Endpoints Use "Organization":
- `/api/organizations` - Uses OrganizationHandler
- `/api/organizations/:id` - Returns Organization entities
- OrganizationService and OrganizationRepository are actively used
#### But Sites Incorrectly Reference "Business":
- `BackendSite.OwnerBusinessID` - **WRONG**: Should be `OwnerOrganizationID`
- `/api/sites/business/:businessId` - **WRONG**: Should be `/api/sites/organization/:organizationId`
- Site schema has `owner_business_id` field - **WRONG**: Should be `owner_organization_id`
**Issue**: Sites should reference Organizations (the parent entity), not just Businesses.
### 3. Frontend Implementation
**Uses "Organization" terminology:**
- `Organization` type throughout
- `OrganizationPage`, `OrganizationContent`, etc.
- Routes: `/organization/:id`
- Context: `OrganizationContext`
**But calls "Business" APIs:**
- `getSitesByBusiness(businessId)` - Function name uses "business"
- `site.OwnerBusinessID` - Field name uses "Business"
- `businessId={organization.ID}` - Treats Organization ID as Business ID
## The Problem
### Current Issues:
1. **Incorrect Field Names:**
- Sites use `OwnerBusinessID` but should use `OwnerOrganizationID`
- Not all Organizations are Businesses (could be government, NGO, etc.)
- Field name implies only Businesses can own sites
2. **API Endpoint Mismatch:**
- `/api/sites/business/:businessId` should be `/api/sites/organization/:organizationId`
- Endpoint name suggests only Businesses can have sites
3. **Missing Business Subtype:**
- Business domain model exists but isn't integrated as Organization subtype
- No way to distinguish Business organizations from other types
- Rich Business metadata (certifications, NACE codes) not accessible
4. **Data Model Confusion:**
- Sites reference "Business" but accept Organization IDs
- No validation that Organization is actually a Business (when needed)
- Potential for data integrity issues
## Current Assumption (Implicit)
The codebase currently assumes:
```
Organization.ID can be used as Business.ID (treating all Organizations as Businesses)
```
But this is:
- ❌ Not all Organizations are Businesses
- ❌ Field names suggest Business-only ownership
- ❌ No way to distinguish Business organizations from others
- ❌ Creates confusion about entity relationships
## Recommended Solution
### Implement Organization as Base Entity with Business Subtype
**Architecture:**
```
Organization (Base)
- ID, Name, Sector, Description, LogoURL, Website, Address, Verified
- Type: "business" | "governmental" | "ngo" | "other"
Business (Subtype/Extension)
- OrganizationID (references Organization)
- LegalForm, Certifications, NACE codes
- StrategicVision, ReadinessMaturity, TrustScore
- BusinessFocus, DriversBarriers
```
**Action Plan:**
1. **Fix Site References (Priority 1):**
- Rename `OwnerBusinessID``OwnerOrganizationID` in Site schema
- Update API: `/api/sites/business/:id``/api/sites/organization/:id`
- Update frontend: `getSitesByBusiness``getSitesByOrganization`
- Sites belong to Organizations (any type)
2. **Add Organization Type Field:**
- Add `Type` or `OrganizationType` field to Organization
- Values: "business", "governmental", "ngo", "other"
- Allows filtering and type-specific features
3. **Implement Business as Extension:**
- Business table/entity references Organization.ID
- One-to-one relationship: Organization → Business (optional)
- Only Business-type organizations have Business records
- Business-specific features only available for Business organizations
4. **Update APIs:**
- Keep `/api/organizations` for all organization types
- Add `/api/organizations/:id/business` for Business-specific data
- Or: `/api/businesses/:organizationId` for Business extension
5. **Frontend Updates:**
- Use Organization everywhere (correct)
- Access Business data when `organization.type === "business"`
- Update components to handle different organization types
**Pros:**
- ✅ Correctly models Organization as parent entity
- ✅ Supports multiple organization types (business, government, NGO)
- ✅ Business metadata available when needed
- ✅ Clear, extensible architecture
- ✅ Minimal breaking changes (mostly field renames)
**Cons:**
- Requires database migration for Site field rename
- Need to handle Business extension relationship
- Some API endpoint changes
## Immediate Fixes Needed
### Phase 1: Fix Site References (Critical)
1. **Backend:**
- Rename `OwnerBusinessID``OwnerOrganizationID` in Site domain model
- Update Site repository and handlers
- Update API endpoint: `/api/sites/business/:id``/api/sites/organization/:id`
- Add database migration
2. **Frontend:**
- Update `BackendSite` schema: `OwnerBusinessID``OwnerOrganizationID`
- Rename `getSitesByBusiness``getSitesByOrganization`
- Update all references in hooks and components
### Phase 2: Add Organization Type (Important)
1. **Backend:**
- Add `Type` field to Organization domain model
- Add migration to set default type for existing records
- Update Organization service and handlers
2. **Frontend:**
- Add `Type` field to Organization schema
- Update UI to display organization type
- Filter/group by type if needed
### Phase 3: Implement Business Extension (Future)
1. **Backend:**
- Create Business extension table/entity
- Add foreign key: `Business.OrganizationID → Organization.ID`
- Create Business service for Business-specific operations
- Add API endpoints for Business data
2. **Frontend:**
- Add Business type/interface
- Fetch Business data when `organization.type === "business"`
- Display Business-specific fields (certifications, etc.)
## Files Affected
- `bugulma/backend/internal/domain/organization.go`
- `bugulma/backend/internal/domain/business.go`
- `bugulma/backend/internal/handler/site_handler.go`
- `bugulma/frontend/schemas/backend/site.ts`
- `bugulma/frontend/services/sites-api.ts`
- `bugulma/frontend/hooks/map/useOrganizationSites.ts`
- All frontend components using "Organization"
## Conclusion
**Correct Architecture:** Organization is the main entity. Business is a subtype for commercial organizations. Other types (governmental, NGO) are also Organizations.
**Current State:** Sites incorrectly reference "Business" when they should reference "Organization". Business extension exists but isn't integrated.
**Recommended Action:**
1. **Phase 1 (Critical)**: Fix Site references to use Organization
2. **Phase 2 (Important)**: Add Organization Type field
3. **Phase 3 (Future)**: Implement Business as optional extension
**Priority:** High - Field names are misleading and don't support the correct architecture where Organizations can be non-business entities.

6
bugulma/Procfile Normal file
View File

@ -0,0 +1,6 @@
# Procfile for Turash Development
# Use with foreman: foreman start
# Or overmind: overmind start
backend: cd backend && go run ./cmd/cli server
frontend: cd frontend && yarn dev

BIN
bugulma/backend/cli Executable file

Binary file not shown.

View File

@ -38,8 +38,174 @@ var heritageUpdateCmd = &cobra.Command{
RunE: runHeritageUpdate,
}
var heritageUpdateJSONCmd = &cobra.Command{
Use: "update-json [json-file]",
Short: "Update localizations from JSON file or stdin",
Long: `Update or create multiple localizations from JSON data.
Supports both single entity updates and bulk operations.
JSON Format for single entity:
{
"entity_type": "site",
"entity_id": "site-123",
"localizations": {
"name": {"en": "Central Market", "tt": "Үзәк Базар"},
"notes": {"en": "Historical building"}
}
}
JSON Format for bulk updates:
[
{
"entity_type": "site",
"entity_id": "site-123",
"localizations": {"name": {"en": "Market"}}
},
{
"entity_type": "heritage_title",
"entity_id": "1",
"localizations": {"title": {"en": "Title"}}
}
]
Use - for stdin input, or provide a file path.`,
Args: cobra.ExactArgs(1),
RunE: runHeritageUpdateJSON,
}
var heritageUntranslatedCmd = &cobra.Command{
Use: "untranslated [locale] [entity-type]",
Short: "Show untranslated content for specified locale",
Long: `Find and display content that is not translated for the specified locale.
Shows entities and fields that are missing translations.
Arguments:
locale Target locale (en, tt)
entity-type Entity type to check (site, heritage_title, heritage_timeline_item, heritage_source, all)
Flags:
--all-sites Include all sites, not just heritage sites (default: heritage sites only)
Examples:
bugulma-cli heritage untranslated en site # Show English untranslated heritage sites
bugulma-cli heritage untranslated en site --all-sites # Show untranslated for all sites
bugulma-cli heritage untranslated tt all # Show all Tatar untranslated content
bugulma-cli heritage untranslated en heritage_title # Show untranslated heritage titles`,
Args: cobra.ExactArgs(2),
RunE: runHeritageUntranslated,
}
var heritageStatsCmd = &cobra.Command{
Use: "stats [entity-type]",
Short: "Show translation statistics and coverage",
Long: `Display comprehensive statistics about translation coverage.
Shows completion percentages, missing translations, and entity counts.
Arguments:
entity-type Entity type to analyze (site, heritage_title, heritage_timeline_item, heritage_source, all)
Flags:
--all-sites Include all sites, not just heritage sites (default: heritage sites only)
Examples:
bugulma-cli heritage stats all # Show all translation stats
bugulma-cli heritage stats site # Show heritage site translation stats
bugulma-cli heritage stats site --all-sites # Show stats for all sites`,
Args: cobra.ExactArgs(1),
RunE: runHeritageStats,
}
var heritageSearchCmd = &cobra.Command{
Use: "search [query] [locale]",
Short: "Search within localized content",
Long: `Search for text within localized content across all entities.
Supports searching in specific locales or all locales.
Arguments:
query Search text (case-insensitive)
locale Target locale (en, tt, ru, all) - optional, defaults to all
Examples:
bugulma-cli heritage search "market" en # Search "market" in English translations
bugulma-cli heritage search "базар" all # Search "базар" in all locales`,
Args: cobra.RangeArgs(1, 2),
RunE: runHeritageSearch,
}
var heritageTranslateCmd = &cobra.Command{
Use: "translate [locale] [entity-type]",
Short: "Auto-translate untranslated content using Ollama",
Long: `Automatically translate untranslated Russian content to the target locale using Ollama.
Finds all untranslated fields and translates them using a local Ollama instance.
Arguments:
locale Target locale (en, tt)
entity-type Entity type to translate (site, heritage_title, heritage_timeline_item, heritage_source, all)
Flags:
--ollama-url Ollama API base URL (default: http://localhost:11434)
--ollama-model Ollama model to use (default: qwen2.5:7b)
--ollama-username Ollama API username (for basic auth)
--ollama-password Ollama API password (for basic auth)
--dry-run Show what would be translated without actually translating
--all-sites Include all sites, not just heritage sites (default: heritage sites only)
Examples:
bugulma-cli heritage translate en site --dry-run # Preview translations for heritage sites
bugulma-cli heritage translate en site --all-sites # Translate all sites (heritage + non-heritage)
bugulma-cli heritage translate en all # Translate all entities to English
bugulma-cli heritage translate tt heritage_title --ollama-model llama3.2 # Use specific model`,
Args: cobra.ExactArgs(2),
RunE: runHeritageTranslate,
}
var heritageTranslateOneCmd = &cobra.Command{
Use: "translate-one [locale] [entity-type] [entity-id]",
Short: "Auto-translate a single entity using Ollama",
Long: `Automatically translate untranslated Russian content for a single entity to the target locale using Ollama.
Arguments:
locale Target locale (en, tt)
entity-type Entity type (site, heritage_title, heritage_timeline_item, heritage_source, organization, etc.)
entity-id ID of the entity to translate
Flags:
--ollama-url Ollama API base URL (default: http://localhost:11434)
--ollama-model Ollama model to use (default: qwen2.5:7b)
--ollama-username Ollama API username (for basic auth)
--ollama-password Ollama API password (for basic auth)
--dry-run Show what would be translated without actually translating
Examples:
bugulma-cli heritage translate-one en site site-123 # Translate a single site
bugulma-cli heritage translate-one en site site-456 --dry-run # Preview translation for a site`,
Args: cobra.ExactArgs(3),
RunE: runHeritageTranslateOne,
}
func init() {
heritageCmd.AddCommand(heritageListCmd, heritageShowCmd, heritageUpdateCmd)
// Flags for translate command
heritageTranslateCmd.Flags().String("ollama-url", "http://localhost:11434", "Ollama API base URL")
heritageTranslateCmd.Flags().String("ollama-model", "qwen2.5:7b", "Ollama model to use")
heritageTranslateCmd.Flags().String("ollama-username", "", "Ollama API username (for basic auth)")
heritageTranslateCmd.Flags().String("ollama-password", "", "Ollama API password (for basic auth)")
heritageTranslateCmd.Flags().Bool("dry-run", false, "Preview translations without saving")
heritageTranslateCmd.Flags().Bool("all-sites", false, "Include all sites, not just heritage sites")
// Flags for translate-one command
heritageTranslateOneCmd.Flags().String("ollama-url", "http://localhost:11434", "Ollama API base URL")
heritageTranslateOneCmd.Flags().String("ollama-model", "qwen2.5:7b", "Ollama model to use")
heritageTranslateOneCmd.Flags().String("ollama-username", "", "Ollama API username (for basic auth)")
heritageTranslateOneCmd.Flags().String("ollama-password", "", "Ollama API password (for basic auth)")
heritageTranslateOneCmd.Flags().Bool("dry-run", false, "Preview translations without saving")
// Flags for untranslated command
heritageUntranslatedCmd.Flags().Bool("all-sites", false, "Include all sites, not just heritage sites")
// Flags for stats command
heritageStatsCmd.Flags().Bool("all-sites", false, "Include all sites, not just heritage sites")
heritageCmd.AddCommand(heritageListCmd, heritageShowCmd, heritageUpdateCmd, heritageUpdateJSONCmd, heritageUntranslatedCmd, heritageStatsCmd, heritageSearchCmd, heritageTranslateCmd, heritageTranslateOneCmd)
}
func runHeritageList(cmd *cobra.Command, args []string) error {
@ -97,3 +263,148 @@ func runHeritageUpdate(cmd *cobra.Command, args []string) error {
return heritage.UpdateLocalization(db, entityType, entityID, field, locale, value)
}
func runHeritageUpdateJSON(cmd *cobra.Command, args []string) error {
cfg, err := getConfig()
if err != nil {
return err
}
db, err := internal.ConnectPostgres(cfg)
if err != nil {
return err
}
return heritage.UpdateLocalizationsFromJSON(db, args[0])
}
func runHeritageUntranslated(cmd *cobra.Command, args []string) error {
cfg, err := getConfig()
if err != nil {
return err
}
db, err := internal.ConnectPostgres(cfg)
if err != nil {
return err
}
locale := args[0]
entityType := args[1]
allSites, _ := cmd.Flags().GetBool("all-sites")
return heritage.ShowUntranslated(db, locale, entityType, allSites)
}
func runHeritageStats(cmd *cobra.Command, args []string) error {
cfg, err := getConfig()
if err != nil {
return err
}
db, err := internal.ConnectPostgres(cfg)
if err != nil {
return err
}
entityType := args[0]
allSites, _ := cmd.Flags().GetBool("all-sites")
return heritage.ShowTranslationStats(db, entityType, allSites)
}
func runHeritageSearch(cmd *cobra.Command, args []string) error {
cfg, err := getConfig()
if err != nil {
return err
}
db, err := internal.ConnectPostgres(cfg)
if err != nil {
return err
}
query := args[0]
locale := "all"
if len(args) > 1 {
locale = args[1]
}
return heritage.SearchTranslations(db, query, locale)
}
func runHeritageTranslate(cmd *cobra.Command, args []string) error {
cfg, err := getConfig()
if err != nil {
return err
}
db, err := internal.ConnectPostgres(cfg)
if err != nil {
return err
}
locale := args[0]
entityType := args[1]
ollamaURL, _ := cmd.Flags().GetString("ollama-url")
ollamaModel, _ := cmd.Flags().GetString("ollama-model")
ollamaUsername, _ := cmd.Flags().GetString("ollama-username")
ollamaPassword, _ := cmd.Flags().GetString("ollama-password")
dryRun, _ := cmd.Flags().GetBool("dry-run")
allSites, _ := cmd.Flags().GetBool("all-sites")
// Fallback to config if flags are empty
if ollamaURL == "" {
ollamaURL = cfg.OllamaURL
}
if ollamaModel == "" {
ollamaModel = cfg.OllamaModel
}
if ollamaUsername == "" {
ollamaUsername = cfg.OllamaUsername
}
if ollamaPassword == "" {
ollamaPassword = cfg.OllamaPassword
}
return heritage.AutoTranslate(db, locale, entityType, ollamaURL, ollamaModel, dryRun, allSites, ollamaUsername, ollamaPassword)
}
func runHeritageTranslateOne(cmd *cobra.Command, args []string) error {
cfg, err := getConfig()
if err != nil {
return err
}
db, err := internal.ConnectPostgres(cfg)
if err != nil {
return err
}
locale := args[0]
entityType := args[1]
entityID := args[2]
ollamaURL, _ := cmd.Flags().GetString("ollama-url")
ollamaModel, _ := cmd.Flags().GetString("ollama-model")
ollamaUsername, _ := cmd.Flags().GetString("ollama-username")
ollamaPassword, _ := cmd.Flags().GetString("ollama-password")
dryRun, _ := cmd.Flags().GetBool("dry-run")
// Fallback to config if flags are empty
if ollamaURL == "" {
ollamaURL = cfg.OllamaURL
}
if ollamaModel == "" {
ollamaModel = cfg.OllamaModel
}
if ollamaUsername == "" {
ollamaUsername = cfg.OllamaUsername
}
if ollamaPassword == "" {
ollamaPassword = cfg.OllamaPassword
}
return heritage.TranslateSingleEntity(db, locale, entityType, entityID, ollamaURL, ollamaModel, dryRun, ollamaUsername, ollamaPassword)
}

View File

@ -1,12 +1,19 @@
package heritage
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"time"
"bugulma/backend/internal/domain"
"bugulma/backend/internal/localization"
"bugulma/backend/internal/localization/handlers"
"bugulma/backend/internal/repository"
"bugulma/backend/internal/service"
"gorm.io/gorm"
)
@ -59,18 +66,9 @@ func ShowEntity(db *gorm.DB, entityType, entityID string) error {
// UpdateLocalization updates or creates a localization for a heritage entity
func UpdateLocalization(db *gorm.DB, entityType, entityID, field, locale, value string) error {
// Validate locale
if locale != "en" && locale != "tt" {
return fmt.Errorf("invalid locale: %s. Use: en or tt", locale)
}
// Validate entity type
validTypes := map[string]bool{
"heritage_title": true,
"heritage_timeline_item": true,
"heritage_source": true,
"site": true,
}
// Initialize repositories and services
locRepo := repository.NewLocalizationRepository(db)
locService := service.NewLocalizationService(locRepo)
// Map shorthand to full type
typeMap := map[string]string{
@ -85,73 +83,13 @@ func UpdateLocalization(db *gorm.DB, entityType, entityID, field, locale, value
entityType = mappedType
}
if !validTypes[entityType] {
return fmt.Errorf("invalid entity type: %s", entityType)
// Use the service to set the localized value
err := locService.SetLocalizedValue(entityType, entityID, field, locale, value)
if err != nil {
return fmt.Errorf("failed to update localization: %w", err)
}
// Validate field
validFields := map[string]map[string]bool{
"heritage_title": {
"title": true,
"content": true,
},
"heritage_timeline_item": {
"title": true,
"content": true,
},
"heritage_source": {
"title": true,
},
"site": {
"name": true,
"notes": true,
"builder_owner": true,
"architect": true,
"original_purpose": true,
"current_use": true,
"style": true,
"materials": true,
},
}
if !validFields[entityType][field] {
return fmt.Errorf("invalid field '%s' for entity type '%s'", field, entityType)
}
// Create or update localization
now := time.Now()
locID := fmt.Sprintf("%s_%s_%s_%s", entityType, entityID, field, locale)
var existing domain.Localization
err := db.Where("id = ?", locID).First(&existing).Error
if err == gorm.ErrRecordNotFound {
// Create new
newLoc := domain.Localization{
ID: locID,
EntityType: entityType,
EntityID: entityID,
Field: field,
Locale: locale,
Value: value,
CreatedAt: now,
UpdatedAt: now,
}
if err := db.Create(&newLoc).Error; err != nil {
return fmt.Errorf("failed to create localization: %w", err)
}
fmt.Printf("✓ Created localization: %s [%s] %s = %s\n", entityType, locale, field, truncate(value, 50))
} else if err != nil {
return fmt.Errorf("error checking existing localization: %w", err)
} else {
// Update existing
existing.Value = value
existing.UpdatedAt = now
if err := db.Save(&existing).Error; err != nil {
return fmt.Errorf("failed to update localization: %w", err)
}
fmt.Printf("✓ Updated localization: %s [%s] %s = %s\n", entityType, locale, field, truncate(value, 50))
}
fmt.Printf("✓ Updated localization: %s [%s] %s = %s\n", entityType, locale, field, truncate(value, 50))
return nil
}
@ -169,8 +107,8 @@ func listTitle(db *gorm.DB) error {
}
func listTimelineItems(db *gorm.DB) error {
var items []domain.HeritageTimelineItem
if err := db.Order(`"order" ASC`).Find(&items).Error; err != nil {
var items []domain.TimelineItem
if err := db.Where("heritage = ?", true).Order(`"order" ASC`).Find(&items).Error; err != nil {
return fmt.Errorf("error fetching timeline items: %w", err)
}
@ -244,7 +182,7 @@ func showTitle(db *gorm.DB, idStr string) error {
}
func showTimelineItem(db *gorm.DB, id string) error {
var item domain.HeritageTimelineItem
var item domain.TimelineItem
if err := db.First(&item, "id = ?", id).Error; err != nil {
return fmt.Errorf("timeline item not found: %w", err)
}
@ -261,7 +199,7 @@ func showTimelineItem(db *gorm.DB, id string) error {
fmt.Printf("Content (RU): %s\n\n", item.Content)
// Show localizations
return showLocalizations(db, "heritage_timeline_item", item.ID)
return showLocalizations(db, "timeline_item", item.ID)
}
func showSource(db *gorm.DB, idStr string) error {
@ -290,13 +228,19 @@ func showSource(db *gorm.DB, idStr string) error {
func showHeritageSite(db *gorm.DB, id string) error {
var site domain.Site
if err := db.Where("id = ? AND heritage_status IS NOT NULL AND heritage_status != ''", id).First(&site).Error; err != nil {
return fmt.Errorf("heritage site not found: %w", err)
if err := db.Where("id = ?", id).First(&site).Error; err != nil {
return fmt.Errorf("site not found: %w", err)
}
fmt.Println("=== HERITAGE SITE ===")
fmt.Printf("ID: %s\n", site.ID)
fmt.Printf("Heritage Status: %s\n", site.HeritageStatus)
// Determine if it's a heritage site
if site.HeritageStatus != "" {
fmt.Println("=== HERITAGE SITE ===")
fmt.Printf("ID: %s\n", site.ID)
fmt.Printf("Heritage Status: %s\n", site.HeritageStatus)
} else {
fmt.Println("=== SITE ===")
fmt.Printf("ID: %s\n", site.ID)
}
if site.YearBuilt != "" {
fmt.Printf("Year Built: %s\n", site.YearBuilt)
}
@ -330,9 +274,12 @@ func showHeritageSite(db *gorm.DB, id string) error {
}
func showLocalizations(db *gorm.DB, entityType, entityID string) error {
var localizations []domain.Localization
if err := db.Where("entity_type = ? AND entity_id = ?", entityType, entityID).
Order("field, locale").Find(&localizations).Error; err != nil {
// Initialize repositories and services
locRepo := repository.NewLocalizationRepository(db)
locService := service.NewLocalizationService(locRepo)
localizations, err := locService.GetAllLocalizedValues(entityType, entityID)
if err != nil {
return fmt.Errorf("error fetching localizations: %w", err)
}
@ -342,16 +289,12 @@ func showLocalizations(db *gorm.DB, entityType, entityID string) error {
}
fmt.Println("=== LOCALIZATIONS ===")
currentField := ""
for _, loc := range localizations {
if loc.Field != currentField {
if currentField != "" {
fmt.Println()
}
currentField = loc.Field
fmt.Printf("%s:\n", strings.ToUpper(loc.Field))
for field, localeValues := range localizations {
fmt.Printf("%s:\n", strings.ToUpper(field))
for locale, value := range localeValues {
fmt.Printf(" [%s] %s\n", locale, truncate(value, 100))
}
fmt.Printf(" [%s] %s\n", loc.Locale, truncate(loc.Value, 100))
fmt.Println()
}
return nil
}
@ -362,3 +305,768 @@ func truncate(s string, maxLen int) string {
}
return s[:maxLen] + "..."
}
// JSON structures for bulk localization updates
// LocalizationUpdate represents a single localization update for a field/locale
type LocalizationUpdate struct {
Locale string `json:"locale"`
Value string `json:"value"`
}
// FieldLocalizations represents all localizations for a specific field
type FieldLocalizations map[string]string // locale -> value
// EntityLocalizations represents all localizations for an entity
type EntityLocalizations struct {
EntityType string `json:"entity_type"`
EntityID string `json:"entity_id"`
Localizations map[string]FieldLocalizations `json:"localizations"` // field -> (locale -> value)
}
// BulkLocalizationUpdate represents either a single entity update or an array of updates
type BulkLocalizationUpdate struct {
Single *EntityLocalizations `json:"-"`
Bulk []*EntityLocalizations `json:"-"`
}
// UnmarshalJSON implements custom unmarshaling to handle both single object and array
func (b *BulkLocalizationUpdate) UnmarshalJSON(data []byte) error {
// Try to unmarshal as single entity first
var single EntityLocalizations
if err := json.Unmarshal(data, &single); err == nil {
b.Single = &single
return nil
}
// Try to unmarshal as array
var bulk []*EntityLocalizations
if err := json.Unmarshal(data, &bulk); err != nil {
return fmt.Errorf("invalid JSON format: must be single entity object or array of entities")
}
b.Bulk = bulk
return nil
}
// UpdateLocalizationsFromJSON processes JSON localization updates using the database
func UpdateLocalizationsFromJSON(db *gorm.DB, filename string) error {
// Read JSON data
jsonData, err := readJSONInput(filename)
if err != nil {
return fmt.Errorf("failed to read JSON input: %w", err)
}
// Parse JSON
var update BulkLocalizationUpdate
if err := json.Unmarshal(jsonData, &update); err != nil {
return fmt.Errorf("failed to parse JSON: %w", err)
}
// Initialize localization service
locRepo := repository.NewLocalizationRepository(db)
locService := service.NewLocalizationService(locRepo)
// Process updates
if update.Single != nil {
return processEntityLocalization(locService, update.Single)
}
if update.Bulk != nil {
return processBulkLocalizations(locService, update.Bulk)
}
return fmt.Errorf("no valid localization data found")
}
// readJSONInput reads JSON from file or stdin
func readJSONInput(filename string) ([]byte, error) {
if filename == "-" {
// Read from stdin
scanner := bufio.NewScanner(os.Stdin)
var data strings.Builder
for scanner.Scan() {
data.WriteString(scanner.Text())
data.WriteString("\n")
}
if err := scanner.Err(); err != nil {
return nil, err
}
return []byte(strings.TrimSpace(data.String())), nil
}
// Read from file
return os.ReadFile(filename)
}
// processEntityLocalization processes localization updates for a single entity
func processEntityLocalization(locService domain.LocalizationService, entity *EntityLocalizations) error {
fmt.Printf("Processing localizations for %s:%s\n", entity.EntityType, entity.EntityID)
if entity.Localizations == nil || len(entity.Localizations) == 0 {
fmt.Println(" No localizations to process")
return nil
}
for field, fieldLocs := range entity.Localizations {
for locale, value := range fieldLocs {
if err := locService.SetLocalizedValue(entity.EntityType, entity.EntityID, field, locale, value); err != nil {
return fmt.Errorf("failed to update %s[%s].%s: %w", entity.EntityType, locale, field, err)
}
fmt.Printf(" ✓ Updated %s [%s] = %s\n", field, locale, truncate(value, 50))
}
}
fmt.Printf("Successfully processed %d fields for %s:%s\n", len(entity.Localizations), entity.EntityType, entity.EntityID)
return nil
}
// processBulkLocalizations processes localization updates for multiple entities
func processBulkLocalizations(locService domain.LocalizationService, entities []*EntityLocalizations) error {
totalFields := 0
fmt.Printf("Processing bulk localizations for %d entities...\n", len(entities))
for i, entity := range entities {
fmt.Printf("\n[%d/%d] ", i+1, len(entities))
if err := processEntityLocalization(locService, entity); err != nil {
return fmt.Errorf("failed to process entity %s:%s: %w", entity.EntityType, entity.EntityID, err)
}
if entity.Localizations != nil {
totalFields += len(entity.Localizations)
}
}
fmt.Printf("\n✅ Successfully processed bulk update: %d entities, %d fields updated\n", len(entities), totalFields)
return nil
}
// ShowUntranslated displays content that is not translated for the specified locale
func ShowUntranslated(db *gorm.DB, targetLocale, entityTypeFilter string, includeAllSites bool) error {
if targetLocale != "en" && targetLocale != "tt" {
return fmt.Errorf("invalid locale: %s. Supported locales: en, tt", targetLocale)
}
fmt.Printf("🔍 Finding untranslated content for locale: %s\n", targetLocale)
fmt.Printf("Entity type filter: %s\n", entityTypeFilter)
if includeAllSites {
fmt.Printf("📋 Including all sites (not just heritage)\n")
}
fmt.Println()
locRepo := repository.NewLocalizationRepository(db)
locService := service.NewLocalizationService(locRepo)
// Get all entities of specified types
entityTypes := getEntityTypesForFilter(entityTypeFilter)
if len(entityTypes) == 0 {
return fmt.Errorf("no valid entity types found for filter: %s", entityTypeFilter)
}
totalUntranslated := 0
totalChecked := 0
for _, entityType := range entityTypes {
fmt.Printf("Checking %s entities...\n", entityType)
entities, err := getAllEntitiesOfType(db, entityType, includeAllSites)
if err != nil {
fmt.Printf(" ❌ Error getting %s entities: %v\n", entityType, err)
continue
}
if len(entities) == 0 {
fmt.Printf(" No %s entities found\n", entityType)
continue
}
untranslatedCount := 0
for _, entity := range entities {
entityID := getEntityID(entity)
localizations, err := locService.GetAllLocalizedValues(entityType, entityID)
if err != nil {
fmt.Printf(" ❌ Error getting localizations for %s:%s: %v\n", entityType, entityID, err)
continue
}
// Check each field for this entity type
fields := getFieldsForEntityType(entityType)
for _, field := range fields {
// Check if field has Russian content (primary language)
hasRussian := hasRussianContent(entity, field)
// Check if field has target locale translation
_, hasTranslation := localizations[field][targetLocale]
if hasRussian && !hasTranslation {
// This field needs translation
russianValue := getRussianContent(entity, field)
if russianValue != "" {
fmt.Printf(" 📝 %s:%s [%s] → %s\n", entityType, entityID, field, truncate(russianValue, 60))
untranslatedCount++
totalUntranslated++
}
}
}
totalChecked++
}
if untranslatedCount > 0 {
fmt.Printf(" Found %d untranslated fields in %d %s entities\n", untranslatedCount, len(entities), entityType)
} else {
fmt.Printf(" ✅ All %d %s entities are translated to %s\n", len(entities), entityType, targetLocale)
}
fmt.Println()
}
fmt.Printf("📊 Summary: Checked %d entities, found %d untranslated fields for locale %s\n",
totalChecked, totalUntranslated, targetLocale)
return nil
}
// ShowTranslationStats displays comprehensive translation statistics
func ShowTranslationStats(db *gorm.DB, entityTypeFilter string, includeAllSites bool) error {
fmt.Printf("📊 Translation Statistics for: %s\n", entityTypeFilter)
if includeAllSites {
fmt.Printf("📋 Including all sites (not just heritage)\n")
}
fmt.Println()
locRepo := repository.NewLocalizationRepository(db)
locService := service.NewLocalizationService(locRepo)
entityTypes := getEntityTypesForFilter(entityTypeFilter)
totalEntities := 0
totalFields := 0
totalTranslations := 0
localeStats := make(map[string]int)
for _, entityType := range entityTypes {
fmt.Printf("📋 %s Statistics:\n", entityType)
entities, err := getAllEntitiesOfType(db, entityType, false) // false = heritage sites only
if err != nil {
fmt.Printf(" ❌ Error: %v\n", err)
continue
}
entityCount := len(entities)
totalEntities += entityCount
if entityCount == 0 {
fmt.Printf(" No entities found\n\n")
continue
}
fields := getFieldsForEntityType(entityType)
fieldCount := len(fields)
fmt.Printf(" 📊 Entities: %d\n", entityCount)
fmt.Printf(" 📝 Fields per entity: %d\n", fieldCount)
// Count complete translations per locale (entities that have all fields translated)
entityLocaleStats := make(map[string]int)
totalEntityTranslations := 0
for _, entity := range entities {
entityID := getEntityID(entity)
localizations, err := locService.GetAllLocalizedValues(entityType, entityID)
if err != nil {
continue
}
// Check each supported locale
for _, locale := range []string{"en", "tt"} {
completeTranslations := 0
for _, field := range fields {
if _, hasTranslation := localizations[field][locale]; hasTranslation {
completeTranslations++
}
}
// If entity has all fields translated for this locale, count it as complete
if completeTranslations == fieldCount {
entityLocaleStats[locale]++
localeStats[locale]++
totalEntityTranslations++
totalTranslations++
}
}
}
totalFields += entityCount * fieldCount
// Calculate coverage based on entities with complete translations
possibleEntityTranslations := entityCount * 2 // 2 locales supported
coverage := float64(totalEntityTranslations) / float64(possibleEntityTranslations) * 100
fmt.Printf(" 🌍 Complete Translations: %d/%d (%.1f%% coverage)\n",
totalEntityTranslations, possibleEntityTranslations, coverage)
if len(entityLocaleStats) > 0 {
fmt.Printf(" 📈 By locale:")
for locale, count := range entityLocaleStats {
percentage := float64(count) / float64(entityCount) * 100
fmt.Printf(" %s:%.1f%%", locale, percentage)
}
fmt.Println()
}
fmt.Println()
}
// Overall statistics
if len(entityTypes) > 1 && totalEntities > 0 {
fmt.Printf("📈 Overall Statistics:\n")
fmt.Printf(" 📊 Total Entities: %d\n", totalEntities)
fmt.Printf(" 📝 Total Fields: %d\n", totalFields)
fmt.Printf(" 🌍 Complete Entity Translations: %d\n", totalTranslations)
// Calculate overall coverage based on entities with complete translations for any locale
possibleOverallTranslations := totalEntities * 2 // 2 locales supported
overallCoverage := float64(totalTranslations) / float64(possibleOverallTranslations) * 100
fmt.Printf(" ✅ Overall Coverage: %.1f%%\n", overallCoverage)
if len(localeStats) > 0 {
fmt.Printf(" 📈 Entity Coverage by locale:")
for locale, count := range localeStats {
percentage := float64(count) / float64(totalEntities) * 100
fmt.Printf(" %s:%.1f%%", locale, percentage)
}
fmt.Println()
}
}
return nil
}
// SearchTranslations searches for text within localized content
func SearchTranslations(db *gorm.DB, query, locale string) error {
if query == "" {
return fmt.Errorf("search query cannot be empty")
}
if locale != "all" && locale != "ru" && locale != "en" && locale != "tt" {
return fmt.Errorf("invalid locale: %s. Use: all, ru, en, tt", locale)
}
fmt.Printf("🔍 Searching for: \"%s\" in locale: %s\n\n", query, locale)
locRepo := repository.NewLocalizationRepository(db)
locService := service.NewLocalizationService(locRepo)
// Search in localizations
results, err := locService.SearchLocalizations(query, locale, 100)
if err != nil {
return fmt.Errorf("search failed: %w", err)
}
if len(results) == 0 {
fmt.Printf("❌ No results found for query: %s\n", query)
return nil
}
fmt.Printf("📋 Found %d matches:\n\n", len(results))
currentEntity := ""
for _, result := range results {
entityKey := fmt.Sprintf("%s:%s", result.EntityType, result.EntityID)
if entityKey != currentEntity {
if currentEntity != "" {
fmt.Println()
}
fmt.Printf("📍 %s\n", entityKey)
currentEntity = entityKey
}
// Highlight the search term in the result
highlighted := highlightSearchTerm(result.Value, query)
fmt.Printf(" [%s] %s: %s\n", result.Locale, result.Field, highlighted)
}
fmt.Printf("\n✅ Search completed. Found %d matches.\n", len(results))
return nil
}
// Helper functions for translation analysis
func getEntityTypesForFilter(entityTypeFilter string) []string {
return localization.GetEntityTypesForFilter(entityTypeFilter)
}
func getFieldsForEntityType(entityType string) []string {
return localization.GetFieldsForEntityType(entityType)
}
func getAllEntitiesOfType(db *gorm.DB, entityType string, includeAllSites bool) ([]interface{}, error) {
options := localization.LoadOptions{
IncludeAllSites: includeAllSites,
}
// Use type-specific loading functions that use the registry
switch entityType {
case "heritage_title":
return loadHeritageTitles(db, options)
case "timeline_item":
return loadHeritageTimelineItems(db, options)
case "heritage_source":
return loadHeritageSources(db, options)
case "site":
return loadSites(db, options)
case "organization":
return loadOrganizations(db, options)
case "geographical_feature":
return loadGeographicalFeatures(db, options)
case "product":
return loadProducts(db, options)
default:
return nil, fmt.Errorf("unsupported entity type: %s", entityType)
}
}
// Type-specific loading functions using the registry
func loadHeritageTitles(db *gorm.DB, options localization.LoadOptions) ([]interface{}, error) {
desc, _ := localization.GetEntityDescriptor("heritage_title")
handler := desc.Handler.(localization.EntityHandler[*domain.HeritageTitle])
entities, err := handler.LoadEntities(db, options)
if err != nil {
return nil, err
}
result := make([]interface{}, len(entities))
for i, entity := range entities {
result[i] = entity
}
return result, nil
}
func loadHeritageTimelineItems(db *gorm.DB, options localization.LoadOptions) ([]interface{}, error) {
desc, _ := localization.GetEntityDescriptor("timeline_item")
handler := desc.Handler.(localization.EntityHandler[*domain.TimelineItem])
entities, err := handler.LoadEntities(db, options)
if err != nil {
return nil, err
}
result := make([]interface{}, len(entities))
for i, entity := range entities {
result[i] = entity
}
return result, nil
}
func loadHeritageSources(db *gorm.DB, options localization.LoadOptions) ([]interface{}, error) {
desc, _ := localization.GetEntityDescriptor("heritage_source")
handler := desc.Handler.(localization.EntityHandler[*domain.HeritageSource])
entities, err := handler.LoadEntities(db, options)
if err != nil {
return nil, err
}
result := make([]interface{}, len(entities))
for i, entity := range entities {
result[i] = entity
}
return result, nil
}
func loadSites(db *gorm.DB, options localization.LoadOptions) ([]interface{}, error) {
desc, _ := localization.GetEntityDescriptor("site")
handler := desc.Handler.(localization.EntityHandler[*domain.Site])
entities, err := handler.LoadEntities(db, options)
if err != nil {
return nil, err
}
result := make([]interface{}, len(entities))
for i, entity := range entities {
result[i] = entity
}
return result, nil
}
func loadOrganizations(db *gorm.DB, options localization.LoadOptions) ([]interface{}, error) {
desc, _ := localization.GetEntityDescriptor("organization")
handler := desc.Handler.(localization.EntityHandler[*domain.Organization])
entities, err := handler.LoadEntities(db, options)
if err != nil {
return nil, err
}
result := make([]interface{}, len(entities))
for i, entity := range entities {
result[i] = entity
}
return result, nil
}
func loadGeographicalFeatures(db *gorm.DB, options localization.LoadOptions) ([]interface{}, error) {
desc, _ := localization.GetEntityDescriptor("geographical_feature")
handler := desc.Handler.(localization.EntityHandler[*domain.GeographicalFeature])
entities, err := handler.LoadEntities(db, options)
if err != nil {
return nil, err
}
result := make([]interface{}, len(entities))
for i, entity := range entities {
result[i] = entity
}
return result, nil
}
func loadProducts(db *gorm.DB, options localization.LoadOptions) ([]interface{}, error) {
desc, _ := localization.GetEntityDescriptor("product")
handler := desc.Handler.(localization.EntityHandler[*domain.Product])
entities, err := handler.LoadEntities(db, options)
if err != nil {
return nil, err
}
result := make([]interface{}, len(entities))
for i, entity := range entities {
result[i] = entity
}
return result, nil
}
func getEntityID(entity interface{}) string {
switch e := entity.(type) {
case *domain.HeritageTitle:
return e.GetEntityID()
case *domain.TimelineItem:
return e.GetEntityID()
case *domain.HeritageSource:
return e.GetEntityID()
case *domain.Site:
return e.GetEntityID()
case *domain.Organization:
return e.GetEntityID()
case *domain.GeographicalFeature:
return e.ID // GeographicalFeature doesn't implement Localizable
case *domain.Product:
return e.ID // Product doesn't implement Localizable
default:
return ""
}
}
func hasRussianContent(entity interface{}, field string) bool {
russianValue := getRussianContent(entity, field)
return russianValue != ""
}
func getRussianContent(entity interface{}, field string) string {
switch e := entity.(type) {
case *domain.HeritageTitle:
handler := handlers.NewHeritageTitleHandler()
return handler.GetFieldValue(e, field)
case *domain.TimelineItem:
handler := handlers.NewTimelineItemHandler()
return handler.GetFieldValue(e, field)
case *domain.HeritageSource:
handler := handlers.NewHeritageSourceHandler()
return handler.GetFieldValue(e, field)
case *domain.Site:
handler := handlers.NewSiteHandler()
return handler.GetFieldValue(e, field)
case *domain.Organization:
handler := handlers.NewOrganizationHandler()
return handler.GetFieldValue(e, field)
case *domain.GeographicalFeature:
handler := handlers.NewGeographicalFeatureHandler()
return handler.GetFieldValue(e, field)
case *domain.Product:
handler := handlers.NewProductHandler()
return handler.GetFieldValue(e, field)
}
return ""
}
func highlightSearchTerm(text, term string) string {
// Simple case-insensitive highlighting
// In a real implementation, you might want to use ANSI color codes
return strings.ReplaceAll(text, term, fmt.Sprintf("*%s*", term))
}
// findExistingTranslationInDB searches the database for an existing translation
// by finding other entities with the same Russian text in the same field that already have a translation
func findExistingTranslationInDB(db *gorm.DB, entityType, field, targetLocale, russianText string) string {
if russianText == "" {
return ""
}
normalized := strings.TrimSpace(russianText)
// Use a simplified database lookup for existing translations
query := fmt.Sprintf(`
SELECT l.value
FROM localizations l
WHERE l.entity_type = '%s'
AND l.field = '%s'
AND l.locale = '%s'
AND EXISTS (
SELECT 1 FROM localizations l2
WHERE l2.entity_type = l.entity_type
AND l2.entity_id = l.entity_id
AND l2.field = l.field
AND l2.locale = 'ru'
AND l2.value LIKE '%%%s%%'
)
LIMIT 1
`, entityType, field, targetLocale, strings.ReplaceAll(normalized, "'", "''"))
var translation string
err := db.Raw(query).Scan(&translation).Error
if err != nil || translation == "" {
return ""
}
return translation
}
// AutoTranslate automatically translates untranslated content using Ollama
func AutoTranslate(db *gorm.DB, targetLocale, entityTypeFilter string, ollamaURL, ollamaModel string, dryRun, allSites bool, ollamaUsername, ollamaPassword string) error {
// Initialize services
locRepo := repository.NewLocalizationRepository(db)
locService := service.NewLocalizationService(locRepo)
entityLoader := service.NewEntityLoaderService(db)
translationService := service.NewTranslationServiceWithAuth(ollamaURL, ollamaModel, ollamaUsername, ollamaPassword)
cacheService := service.NewTranslationCacheService(locRepo, locService)
workflowService := service.NewTranslationWorkflowService(locRepo, locService, entityLoader, translationService, cacheService)
orchestrationService := service.NewTranslationOrchestrationService(workflowService, entityLoader, locService, translationService, cacheService)
// Check Ollama availability
fmt.Printf("🔍 Checking Ollama service...\n")
if err := translationService.HealthCheck(); err != nil {
return fmt.Errorf("ollama service unavailable: %w\nPlease ensure Ollama is running at %s", err, ollamaURL)
}
fmt.Printf("✅ Ollama service is available\n\n")
// Display configuration
fmt.Printf("🤖 Auto-translation using Ollama\n")
fmt.Printf(" Model: %s\n", ollamaModel)
fmt.Printf(" Target locale: %s\n", targetLocale)
fmt.Printf(" Entity filter: %s\n", entityTypeFilter)
if allSites {
fmt.Printf(" Including all sites (not just heritage)\n")
}
if dryRun {
fmt.Printf(" Mode: DRY RUN (preview only)\n")
}
fmt.Println()
// Execute translation orchestration
ctx := context.Background()
result, err := orchestrationService.TranslateAllEntities(ctx, targetLocale, entityTypeFilter, dryRun, allSites)
if err != nil {
return fmt.Errorf("translation orchestration failed: %w", err)
}
// Display results
displayTranslationResults(result, dryRun)
return nil
}
// TranslateSingleEntity translates a single entity by ID
func TranslateSingleEntity(db *gorm.DB, targetLocale, entityType, entityID string, ollamaURL, ollamaModel string, dryRun bool, ollamaUsername, ollamaPassword string) error {
// Initialize services
locRepo := repository.NewLocalizationRepository(db)
locService := service.NewLocalizationService(locRepo)
entityLoader := service.NewEntityLoaderService(db)
translationService := service.NewTranslationServiceWithAuth(ollamaURL, ollamaModel, ollamaUsername, ollamaPassword)
cacheService := service.NewTranslationCacheService(locRepo, locService)
workflowService := service.NewTranslationWorkflowService(locRepo, locService, entityLoader, translationService, cacheService)
orchestrationService := service.NewTranslationOrchestrationService(workflowService, entityLoader, locService, translationService, cacheService)
if !dryRun {
fmt.Printf("🔍 Checking Ollama service...\n")
if err := translationService.HealthCheck(); err != nil {
return fmt.Errorf("ollama service unavailable: %w\nPlease ensure Ollama is running at %s", err, ollamaURL)
}
fmt.Printf("✅ Ollama service is available\n\n")
}
// Display configuration
fmt.Printf("🤖 Auto-translating single entity using Ollama\n")
fmt.Printf(" Model: %s\n", ollamaModel)
fmt.Printf(" Target locale: %s\n", targetLocale)
fmt.Printf(" Entity type: %s\n", entityType)
fmt.Printf(" Entity ID: %s\n", entityID)
if dryRun {
fmt.Printf(" Mode: DRY RUN (preview only)\n")
}
fmt.Println()
// Execute translation
ctx := context.Background()
result, err := orchestrationService.TranslateSingleEntity(ctx, entityType, entityID, targetLocale, dryRun)
if err != nil {
return fmt.Errorf("translation failed: %w", err)
}
// Display results
fmt.Printf("📊 Translation Results:\n")
if dryRun {
fmt.Printf(" 🔍 Preview mode: %d fields would be translated\n", result.Skipped)
} else {
fmt.Printf(" ✅ Successfully translated: %d fields\n", result.Translated)
}
if result.Cached > 0 {
fmt.Printf(" ⚡ Cache hits: %d\n", result.Cached)
}
if result.Reused > 0 {
fmt.Printf(" ♻️ Reused translations: %d\n", result.Reused)
}
if result.Skipped > 0 {
fmt.Printf(" ⏭️ Skipped (already translated): %d\n", result.Skipped)
}
if result.Error != nil {
fmt.Printf(" ❌ Error: %v\n", result.Error)
}
return nil
}
// displayTranslationResults formats and displays translation results
func displayTranslationResults(result *service.OrchestrationResult, dryRun bool) {
// Display per-entity-type results
for entityType, entityResult := range result.EntityResults {
if entityResult.Processed == 0 {
continue
}
fmt.Printf("📋 %s: %d processed", entityType, entityResult.Processed)
if entityResult.Translated > 0 {
fmt.Printf(", %d translated", entityResult.Translated)
}
if entityResult.Cached > 0 {
fmt.Printf(", %d cached", entityResult.Cached)
}
if entityResult.Reused > 0 {
fmt.Printf(", %d reused", entityResult.Reused)
}
if entityResult.Errors > 0 {
fmt.Printf(", %d errors", entityResult.Errors)
}
fmt.Println()
}
// Summary
fmt.Printf("\n📊 Translation Summary:\n")
if dryRun {
fmt.Printf(" 🔍 Preview mode: %d fields would be translated\n", result.Skipped)
} else {
fmt.Printf(" ✅ Successfully translated: %d fields\n", result.Translated)
}
if result.Cached > 0 {
fmt.Printf(" ⚡ Cache hits: %d (saved API calls)\n", result.Cached)
}
if result.Reused > 0 {
fmt.Printf(" ♻️ Reused translations: %d\n", result.Reused)
}
if result.Translated > 0 {
fmt.Printf(" 🔄 API translations: %d\n", result.Translated)
}
if result.Errors > 0 {
fmt.Printf(" ❌ Errors: %d\n", result.Errors)
}
fmt.Printf(" ⏱️ Duration: %v\n", result.Duration)
}

View File

@ -43,6 +43,7 @@ func init() {
rootCmd.AddCommand(migrateCmd)
rootCmd.AddCommand(syncCmd)
rootCmd.AddCommand(heritageCmd)
rootCmd.AddCommand(userCmd)
}
// getConfig loads configuration using the global configPath flag
@ -59,4 +60,3 @@ func isVerbose() bool {
func isQuiet() bool {
return quiet
}

View File

@ -107,6 +107,8 @@ func runSyncGraph(cmd *cobra.Command, args []string) error {
flowGraphRepo := repository.NewGraphResourceFlowRepository(driver, cfg.Neo4jDatabase)
matchGraphRepo := repository.NewGraphMatchRepository(driver, cfg.Neo4jDatabase)
sharedAssetGraphRepo := repository.NewGraphSharedAssetRepository(driver, cfg.Neo4jDatabase)
productGraphRepo := repository.NewGraphProductRepository(driver, cfg.Neo4jDatabase)
serviceGraphRepo := repository.NewGraphServiceRepository(driver, cfg.Neo4jDatabase)
// Initialize sync service
syncService := service.NewGraphSyncService(
@ -116,6 +118,8 @@ func runSyncGraph(cmd *cobra.Command, args []string) error {
flowGraphRepo,
matchGraphRepo,
sharedAssetGraphRepo,
productGraphRepo,
serviceGraphRepo,
)
if syncDryRun {

View File

@ -0,0 +1,278 @@
package cmd
import (
"context"
"fmt"
"log"
"time"
"bugulma/backend/cmd/cli/internal"
"bugulma/backend/internal/domain"
"bugulma/backend/internal/repository"
"github.com/google/uuid"
"github.com/spf13/cobra"
"golang.org/x/crypto/bcrypt"
)
var (
userEmail string
userPassword string
userName string
userRole string
resetMode bool
setupMode bool
)
var userCmd = &cobra.Command{
Use: "user",
Short: "User management operations",
Long: `Manage user accounts including password resets and bulk user setup.
Supports creating users, resetting passwords, and setting up default users.`,
}
var userCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a new user account",
Long: `Create a new user account with the specified email, password, and role.
If no password is provided, it will be generated randomly.`,
RunE: runUserCreate,
}
var userResetPasswordCmd = &cobra.Command{
Use: "reset-password",
Short: "Reset a user's password",
Long: `Reset a user's password. Generates a bcrypt hash that can be used
to update the user's password in the database.`,
RunE: runUserResetPassword,
}
var userSetupCmd = &cobra.Command{
Use: "setup",
Short: "Set up default users",
Long: `Create default users for each role if they don't exist.
Creates admin, regular user, content manager, and viewer accounts.
Existing users are updated if their roles differ.`,
RunE: runUserSetup,
}
func init() {
// Create command flags
userCreateCmd.Flags().StringVarP(&userEmail, "email", "e", "", "User email (required)")
userCreateCmd.Flags().StringVarP(&userPassword, "password", "p", "", "User password (if not provided, will be prompted)")
userCreateCmd.Flags().StringVarP(&userName, "name", "n", "", "User name (required)")
userCreateCmd.Flags().StringVarP(&userRole, "role", "r", "user", "User role (admin, user, content_manager, viewer)")
userCreateCmd.MarkFlagRequired("email")
userCreateCmd.MarkFlagRequired("name")
// Reset password command flags
userResetPasswordCmd.Flags().StringVarP(&userPassword, "password", "p", "", "New password (required)")
userResetPasswordCmd.MarkFlagRequired("password")
// Add subcommands
userCmd.AddCommand(userCreateCmd)
userCmd.AddCommand(userResetPasswordCmd)
userCmd.AddCommand(userSetupCmd)
}
func runUserCreate(cmd *cobra.Command, args []string) error {
cfg, err := getConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
// Connect to database
db, err := internal.ConnectPostgres(cfg)
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
}
userRepo := repository.NewUserRepository(db)
ctx := context.Background()
// Validate role
var role domain.UserRole
switch userRole {
case "admin":
role = domain.UserRoleAdmin
case "user":
role = domain.UserRoleUser
case "content_manager":
role = domain.UserRoleContentManager
case "viewer":
role = domain.UserRoleViewer
default:
return fmt.Errorf("invalid role: %s. Must be one of: admin, user, content_manager, viewer", userRole)
}
// Check if user exists
existing, err := userRepo.GetByEmail(ctx, userEmail)
if err == nil && existing != nil {
return fmt.Errorf("user with email %s already exists", userEmail)
}
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(userPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
// Create user
user := &domain.User{
ID: uuid.New().String(),
Email: userEmail,
Name: userName,
Password: string(hashedPassword),
Role: role,
IsActive: true,
Permissions: "[]",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := userRepo.Create(ctx, user); err != nil {
return fmt.Errorf("failed to create user: %w", err)
}
if isVerbose() {
fmt.Printf("✓ Created user %s (%s) with role %s\n", userEmail, userName, role)
}
return nil
}
func runUserResetPassword(cmd *cobra.Command, args []string) error {
if len(args) != 1 {
return fmt.Errorf("email argument is required")
}
email := args[0]
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(userPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
fmt.Println("-- Run this SQL to update the user password:")
fmt.Printf("UPDATE users SET password = '%s' WHERE email = '%s';\n", string(hashedPassword), email)
if isVerbose() {
fmt.Printf("✓ Generated password hash for user: %s\n", email)
fmt.Printf("Password: %s\n", userPassword)
}
return nil
}
func runUserSetup(cmd *cobra.Command, args []string) error {
cfg, err := getConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
// Connect to database
db, err := internal.ConnectPostgres(cfg)
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
}
userRepo := repository.NewUserRepository(db)
ctx := context.Background()
// Define users for each role
users := []struct {
email string
name string
password string
role domain.UserRole
}{
{
email: "admin@tuganyak.dev",
name: "Admin User",
password: "admin123",
role: domain.UserRoleAdmin,
},
{
email: "user@tuganyak.dev",
name: "Regular User",
password: "user123",
role: domain.UserRoleUser,
},
{
email: "content@tuganyak.dev",
name: "Content Manager",
password: "content123",
role: domain.UserRoleContentManager,
},
{
email: "viewer@tuganyak.dev",
name: "Viewer User",
password: "viewer123",
role: domain.UserRoleViewer,
},
}
for _, u := range users {
// Check if user exists
existing, err := userRepo.GetByEmail(ctx, u.email)
if err == nil && existing != nil {
// Update role if different
if existing.Role != u.role {
if isVerbose() {
fmt.Printf("Updating user %s: role %s -> %s\n", u.email, existing.Role, u.role)
}
if err := userRepo.UpdateRole(ctx, existing.ID, u.role); err != nil {
log.Printf("Failed to update role for %s: %v", u.email, err)
continue
}
if !isQuiet() {
fmt.Printf("✓ Updated role for %s to %s\n", u.email, u.role)
}
} else {
if !isQuiet() {
fmt.Printf("✓ User %s already exists with role %s\n", u.email, u.role)
}
}
continue
}
// Create new user
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(u.password), bcrypt.DefaultCost)
if err != nil {
log.Printf("Failed to hash password for %s: %v", u.email, err)
continue
}
user := &domain.User{
ID: uuid.New().String(),
Email: u.email,
Name: u.name,
Password: string(hashedPassword),
Role: u.role,
IsActive: true,
Permissions: "[]",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := userRepo.Create(ctx, user); err != nil {
log.Printf("Failed to create user %s: %v", u.email, err)
continue
}
if !isQuiet() {
fmt.Printf("✓ Created user %s with role %s (password: %s)\n", u.email, u.role, u.password)
}
}
if !isQuiet() {
fmt.Println("\nAll users setup complete!")
fmt.Println("\nLogin credentials:")
fmt.Println(" Admin: admin@tuganyak.dev / admin123")
fmt.Println(" User: user@tuganyak.dev / user123")
fmt.Println(" Content Mgr: content@tuganyak.dev / content123")
fmt.Println(" Viewer: viewer@tuganyak.dev / viewer123")
}
return nil
}

View File

@ -0,0 +1,53 @@
package domain
import (
"context"
"time"
)
// ActivityAction represents the type of action performed
type ActivityAction string
const (
ActivityActionCreate ActivityAction = "create"
ActivityActionUpdate ActivityAction = "update"
ActivityActionDelete ActivityAction = "delete"
ActivityActionVerify ActivityAction = "verify"
ActivityActionReject ActivityAction = "reject"
ActivityActionLogin ActivityAction = "login"
ActivityActionLogout ActivityAction = "logout"
ActivityActionView ActivityAction = "view"
ActivityActionExport ActivityAction = "export"
ActivityActionImport ActivityAction = "import"
ActivityActionOther ActivityAction = "other"
)
// ActivityLog represents a system activity log entry
type ActivityLog struct {
ID string `gorm:"primaryKey;type:text"`
UserID string `gorm:"type:text;not null;index"`
Action ActivityAction `gorm:"type:varchar(50);not null;index"`
TargetType string `gorm:"type:varchar(50);not null;index"` // organization, user, page, etc.
TargetID string `gorm:"type:text;not null;index"`
Metadata string `gorm:"type:jsonb"` // JSON object with additional data
IPAddress string `gorm:"type:varchar(45)"` // IPv4 or IPv6
UserAgent string `gorm:"type:text"`
Timestamp time.Time `gorm:"not null;index"`
// Associations
User *User `gorm:"foreignKey:UserID"`
}
// TableName specifies the table name for GORM
func (ActivityLog) TableName() string {
return "activity_logs"
}
// ActivityLogRepository defines the interface for activity log operations
type ActivityLogRepository interface {
Create(ctx context.Context, activity *ActivityLog) error
GetByUser(ctx context.Context, userID string, limit, offset int) ([]*ActivityLog, int64, error)
GetByTarget(ctx context.Context, targetType, targetID string, limit, offset int) ([]*ActivityLog, int64, error)
GetRecent(ctx context.Context, limit int) ([]*ActivityLog, error)
GetByAction(ctx context.Context, action ActivityAction, limit, offset int) ([]*ActivityLog, int64, error)
}

View File

@ -0,0 +1,148 @@
package domain
import (
"context"
"fmt"
"time"
"github.com/lib/pq"
"gorm.io/datatypes"
)
// CommunityListingType defines the type of community listing
type CommunityListingType string
const (
CommunityListingTypeProduct CommunityListingType = "product"
CommunityListingTypeService CommunityListingType = "service"
CommunityListingTypeTool CommunityListingType = "tool"
CommunityListingTypeSkill CommunityListingType = "skill"
CommunityListingTypeNeed CommunityListingType = "need"
)
// CommunityListingPriceType defines how the listing is priced
type CommunityListingPriceType string
const (
CommunityListingPriceTypeFree CommunityListingPriceType = "free"
CommunityListingPriceTypeSale CommunityListingPriceType = "sale"
CommunityListingPriceTypeRent CommunityListingPriceType = "rent"
CommunityListingPriceTypeTrade CommunityListingPriceType = "trade"
CommunityListingPriceTypeBorrow CommunityListingPriceType = "borrow"
)
// CommunityListingStatus defines the status of a listing
type CommunityListingStatus string
const (
CommunityListingStatusActive CommunityListingStatus = "active"
CommunityListingStatusReserved CommunityListingStatus = "reserved"
CommunityListingStatusCompleted CommunityListingStatus = "completed"
CommunityListingStatusArchived CommunityListingStatus = "archived"
)
// CommunityListingCondition defines the condition of a product/tool
type CommunityListingCondition string
const (
CommunityListingConditionNew CommunityListingCondition = "new"
CommunityListingConditionLikeNew CommunityListingCondition = "like_new"
CommunityListingConditionGood CommunityListingCondition = "good"
CommunityListingConditionFair CommunityListingCondition = "fair"
CommunityListingConditionNeedsRepair CommunityListingCondition = "needs_repair"
)
// CommunityListing represents a listing created by a community member (user)
// This extends the platform beyond business-only listings to enable "I Don't Know Who Has What" for citizens
type CommunityListing struct {
ID string `gorm:"primaryKey;type:text"`
UserID string `gorm:"not null;type:text;index"` // References users table
User *User `gorm:"foreignKey:UserID"` // User who created the listing
// Listing Information
Title string `gorm:"not null;type:varchar(255);index"`
Description string `gorm:"type:text"`
ListingType CommunityListingType `gorm:"not null;type:varchar(50);index"`
Category string `gorm:"not null;type:varchar(100);index"`
Subcategory string `gorm:"type:varchar(100)"`
// For Products/Tools
Condition *CommunityListingCondition `gorm:"type:varchar(50)"` // Condition of item (new, like_new, good, etc.)
Price *float64 `gorm:"type:decimal(10,2)"` // NULL = free
PriceType *CommunityListingPriceType `gorm:"type:varchar(50)"` // free, sale, rent, trade, borrow
// For Services/Skills
ServiceType *string `gorm:"type:varchar(50)"` // 'offering' or 'seeking'
Rate *float64 `gorm:"type:decimal(10,2)"` // Rate for service/skill
RateType *string `gorm:"type:varchar(50)"` // 'hourly', 'fixed', 'negotiable', 'free', 'trade'
// Availability
AvailabilityStatus string `gorm:"type:varchar(20);default:'available';index"` // available, limited, reserved, unavailable
AvailabilitySchedule datatypes.JSON `gorm:"type:jsonb"` // Time-based availability schedule
QuantityAvailable *int `gorm:"type:integer"` // For products (NULL = unlimited)
// Location
Location Point `gorm:"type:geometry(Point,4326)"` // PostGIS geometry point
PickupAvailable bool `gorm:"default:true"` // Can be picked up
DeliveryAvailable bool `gorm:"default:false"` // Can be delivered
DeliveryRadiusKm *float64 `gorm:"type:decimal(5,2)"` // Delivery radius in km
// Media
Images pq.StringArray `gorm:"type:text[]"` // Array of image URLs
// Metadata
Tags pq.StringArray `gorm:"type:text[]"` // Searchable tags array
SearchKeywords string `gorm:"type:text"` // Full-text search keywords
// Trust & Verification
UserRating *float64 `gorm:"type:decimal(3,2)"` // Average rating from reviews (0-5)
ReviewCount int `gorm:"default:0"` // Number of reviews
Verified bool `gorm:"default:false"` // Platform verification
// Status
Status CommunityListingStatus `gorm:"type:varchar(20);default:'active';index"` // active, reserved, completed, archived
// Timestamps
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
ExpiresAt *time.Time `gorm:"type:timestamp with time zone"` // Auto-archive after expiration
}
// TableName specifies the table name for GORM
func (CommunityListing) TableName() string {
return "community_listings"
}
// Validate performs business rule validation
func (cl *CommunityListing) Validate() error {
if cl.Title == "" {
return fmt.Errorf("listing title cannot be empty")
}
if cl.Category == "" {
return fmt.Errorf("listing category cannot be empty")
}
if cl.UserID == "" {
return fmt.Errorf("user ID cannot be empty")
}
if cl.Price != nil && *cl.Price < 0 {
return fmt.Errorf("price cannot be negative")
}
if cl.Rate != nil && *cl.Rate < 0 {
return fmt.Errorf("rate cannot be negative")
}
return nil
}
// CommunityListingRepository defines operations for community listing management
type CommunityListingRepository interface {
Create(ctx context.Context, listing *CommunityListing) error
GetByID(ctx context.Context, id string) (*CommunityListing, error)
GetByUser(ctx context.Context, userID string) ([]*CommunityListing, error)
GetByType(ctx context.Context, listingType CommunityListingType) ([]*CommunityListing, error)
GetByCategory(ctx context.Context, category string) ([]*CommunityListing, error)
SearchWithLocation(ctx context.Context, query string, location *Point, radiusKm float64) ([]*CommunityListing, error)
GetNearby(ctx context.Context, lat, lng, radiusKm float64) ([]*CommunityListing, error)
Update(ctx context.Context, listing *CommunityListing) error
Delete(ctx context.Context, id string) error
GetAll(ctx context.Context) ([]*CommunityListing, error)
}

View File

@ -0,0 +1,184 @@
package domain
import (
"context"
"time"
"gorm.io/datatypes"
)
// PageStatus represents the publication status of a page
type PageStatus string
const (
PageStatusDraft PageStatus = "draft"
PageStatusPublished PageStatus = "published"
PageStatusArchived PageStatus = "archived"
)
// PageVisibility represents who can view the page
type PageVisibility string
const (
PageVisibilityPublic PageVisibility = "public"
PageVisibilityPrivate PageVisibility = "private"
PageVisibilityAdmin PageVisibility = "admin"
)
// StaticPage represents a static content page
type StaticPage struct {
ID string `gorm:"primaryKey;type:text"`
Slug string `gorm:"uniqueIndex;not null;type:text"`
Title string `gorm:"type:text;not null"`
Content string `gorm:"type:text"`
MetaDescription string `gorm:"type:text"`
SEOKeywords datatypes.JSON `gorm:"type:jsonb;default:'[]'"` // []string
Status PageStatus `gorm:"type:varchar(20);default:'draft'"`
Visibility PageVisibility `gorm:"type:varchar(20);default:'public'"`
Template string `gorm:"type:varchar(100)"`
PublishedAt *time.Time `gorm:"index"`
CreatedBy string `gorm:"type:text"`
UpdatedBy string `gorm:"type:text"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
// TableName specifies the table name for GORM
func (StaticPage) TableName() string {
return "static_pages"
}
// AnnouncementPriority represents the priority of an announcement
type AnnouncementPriority string
const (
AnnouncementPriorityLow AnnouncementPriority = "low"
AnnouncementPriorityNormal AnnouncementPriority = "normal"
AnnouncementPriorityHigh AnnouncementPriority = "high"
AnnouncementPriorityUrgent AnnouncementPriority = "urgent"
)
// AnnouncementDisplayType represents how the announcement is displayed
type AnnouncementDisplayType string
const (
AnnouncementDisplayBanner AnnouncementDisplayType = "banner"
AnnouncementDisplayModal AnnouncementDisplayType = "modal"
AnnouncementDisplayNotification AnnouncementDisplayType = "notification"
)
// AnnouncementTargetAudience represents who should see the announcement
type AnnouncementTargetAudience string
const (
AnnouncementTargetAll AnnouncementTargetAudience = "all"
AnnouncementTargetOrganizations AnnouncementTargetAudience = "organizations"
AnnouncementTargetUsers AnnouncementTargetAudience = "users"
AnnouncementTargetSpecific AnnouncementTargetAudience = "specific"
)
// Announcement represents a system announcement
type Announcement struct {
ID string `gorm:"primaryKey;type:text"`
Title string `gorm:"type:text;not null"`
Content string `gorm:"type:text;not null"`
Priority AnnouncementPriority `gorm:"type:varchar(20);default:'normal'"`
DisplayType AnnouncementDisplayType `gorm:"type:varchar(20);default:'banner'"`
TargetAudience AnnouncementTargetAudience `gorm:"type:varchar(20);default:'all'"`
TargetGroups datatypes.JSON `gorm:"type:jsonb;default:'[]'"` // []string - for specific groups
StartDate *time.Time `gorm:"index"`
EndDate *time.Time `gorm:"index"`
IsActive bool `gorm:"default:true;index"`
Views int64 `gorm:"default:0"`
Clicks int64 `gorm:"default:0"`
Dismissals int64 `gorm:"default:0"`
CreatedBy string `gorm:"type:text"`
UpdatedBy string `gorm:"type:text"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
// TableName specifies the table name for GORM
func (Announcement) TableName() string {
return "announcements"
}
// MediaAssetType represents the type of media asset
type MediaAssetType string
const (
MediaAssetTypeImage MediaAssetType = "image"
MediaAssetTypeVideo MediaAssetType = "video"
MediaAssetTypeDocument MediaAssetType = "document"
MediaAssetTypeAudio MediaAssetType = "audio"
)
// MediaAsset represents a media file
type MediaAsset struct {
ID string `gorm:"primaryKey;type:text"`
Filename string `gorm:"type:text;not null"`
OriginalName string `gorm:"type:text;not null"`
URL string `gorm:"type:text;not null"`
Type MediaAssetType `gorm:"type:varchar(20);not null;index"`
MimeType string `gorm:"type:varchar(100)"`
Size int64 `gorm:"type:bigint"` // Size in bytes
Width *int `gorm:"type:integer"` // For images/videos
Height *int `gorm:"type:integer"` // For images/videos
Duration *int `gorm:"type:integer"` // For videos/audio in seconds
AltText string `gorm:"type:text"` // For images
Tags datatypes.JSON `gorm:"type:jsonb;default:'[]'"` // []string
UploadedBy string `gorm:"type:text"`
CreatedAt time.Time `gorm:"autoCreateTime;index"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
// TableName specifies the table name for GORM
func (MediaAsset) TableName() string {
return "media_assets"
}
// StaticPageRepository defines the interface for static page operations
type StaticPageRepository interface {
Create(ctx context.Context, page *StaticPage) error
GetByID(ctx context.Context, id string) (*StaticPage, error)
GetBySlug(ctx context.Context, slug string) (*StaticPage, error)
GetAll(ctx context.Context) ([]*StaticPage, error)
Update(ctx context.Context, page *StaticPage) error
Delete(ctx context.Context, id string) error
Search(ctx context.Context, query string) ([]*StaticPage, error)
}
// AnnouncementRepository defines the interface for announcement operations
type AnnouncementRepository interface {
Create(ctx context.Context, announcement *Announcement) error
GetByID(ctx context.Context, id string) (*Announcement, error)
GetAll(ctx context.Context, filters AnnouncementFilters) ([]*Announcement, error)
GetActive(ctx context.Context) ([]*Announcement, error)
Update(ctx context.Context, announcement *Announcement) error
Delete(ctx context.Context, id string) error
RecordView(ctx context.Context, id string) error
RecordClick(ctx context.Context, id string) error
RecordDismissal(ctx context.Context, id string) error
}
type AnnouncementFilters struct {
IsActive *bool
Priority *AnnouncementPriority
StartDate *time.Time
EndDate *time.Time
}
// MediaAssetRepository defines the interface for media asset operations
type MediaAssetRepository interface {
Create(ctx context.Context, asset *MediaAsset) error
GetByID(ctx context.Context, id string) (*MediaAsset, error)
GetAll(ctx context.Context, filters MediaAssetFilters) ([]*MediaAsset, error)
Update(ctx context.Context, asset *MediaAsset) error
Delete(ctx context.Context, id string) error
Search(ctx context.Context, query string) ([]*MediaAsset, error)
}
type MediaAssetFilters struct {
Type *MediaAssetType
Tags []string
}

View File

@ -32,13 +32,13 @@ func TestGeographicalFeature_TableName(t *testing.T) {
func TestGeographicalFeature_JSONSerialization(t *testing.T) {
feature := &domain.GeographicalFeature{
ID: "test-id",
Name: "Test Feature",
FeatureType: domain.GeographicalFeatureTypeRoad,
OSMType: "way",
OSMID: "123456",
Properties: datatypes.JSON(`{"highway": "primary", "surface": "asphalt"}`),
Source: "osm",
ID: "test-id",
Name: "Test Feature",
FeatureType: domain.GeographicalFeatureTypeRoad,
OSMType: "way",
OSMID: "123456",
Properties: datatypes.JSON(`{"highway": "primary", "surface": "asphalt"}`),
Source: "osm",
QualityScore: 0.85,
}
@ -69,8 +69,8 @@ func TestGeographicalFeature_JSONSerialization(t *testing.T) {
func TestTransportProfile_DefaultValues(t *testing.T) {
profile := domain.TransportProfile{
CostPerKm: 0.12,
SpeedKmH: 60.0,
MaxCapacity: 25.0,
SpeedKmH: 60.0,
MaxCapacity: 25.0,
EnvironmentalFactor: 1.0,
}
@ -83,12 +83,12 @@ func TestTransportProfile_DefaultValues(t *testing.T) {
func TestTransportOption_JSONSerialization(t *testing.T) {
option := &domain.TransportOption{
TransportMode: domain.TransportModeTruck,
DistanceKm: 150.5,
CostEur: 18.06,
TimeHours: 2.508,
EnvironmentalScore: 8.5,
DistanceKm: 150.5,
CostEur: 18.06,
TimeHours: 2.508,
EnvironmentalScore: 8.5,
CapacityUtilization: 85.0,
OverallScore: 7.2,
OverallScore: 7.2,
}
// Test JSON marshaling
@ -117,12 +117,12 @@ func TestTransportOption_JSONSerialization(t *testing.T) {
func TestGeographicalFeature_PropertiesJSON(t *testing.T) {
properties := map[string]interface{}{
"name": "Main Street",
"highway": "primary",
"maxspeed": "50",
"surface": "asphalt",
"lanes": 2,
"oneway": true,
"name": "Main Street",
"highway": "primary",
"maxspeed": "50",
"surface": "asphalt",
"lanes": 2,
"oneway": true,
}
jsonData, err := json.Marshal(properties)
@ -161,8 +161,8 @@ func TestGeographicalFeature_EmptyProperties(t *testing.T) {
func TestTransportProfile_Calculations(t *testing.T) {
profile := domain.TransportProfile{
CostPerKm: 0.15,
SpeedKmH: 80.0,
MaxCapacity: 30.0,
SpeedKmH: 80.0,
MaxCapacity: 30.0,
EnvironmentalFactor: 0.9,
}
@ -178,5 +178,5 @@ func TestTransportProfile_Calculations(t *testing.T) {
// Test capacity utilization
load := 20.0
utilization := (load / profile.MaxCapacity) * 100
assert.Equal(t, 66.66666666666667, utilization)
assert.InDelta(t, 66.66666666666667, utilization, 1e-13)
}

View File

@ -29,33 +29,6 @@ func (h *HeritageTitle) GetEntityID() string {
return fmt.Sprintf("%d", h.ID)
}
// HeritageTimelineItem represents a historical event or period in Bugulma's history
type HeritageTimelineItem struct {
ID string `gorm:"primaryKey;type:varchar(50)" json:"id"`
Title string `gorm:"type:varchar(255);not null" json:"title"`
Content string `gorm:"type:text;not null" json:"content"`
ImageURL string `gorm:"type:text" json:"image_url"`
IconName string `gorm:"type:varchar(50);not null" json:"icon_name"`
Order int `gorm:"not null" json:"order"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
}
// TableName specifies the table name for HeritageTimelineItem
func (HeritageTimelineItem) TableName() string {
return "heritage_timeline_items"
}
// GetEntityType implements Localizable interface
func (h *HeritageTimelineItem) GetEntityType() string {
return "heritage_timeline_item"
}
// GetEntityID implements Localizable interface
func (h *HeritageTimelineItem) GetEntityID() string {
return h.ID
}
// HeritageSource represents a reference or source citation
type HeritageSource struct {
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
@ -83,7 +56,7 @@ func (h *HeritageSource) GetEntityID() string {
// HeritageData represents the complete heritage page data
type HeritageData struct {
Title *HeritageTitle `json:"title"`
TimelineItems []HeritageTimelineItem `json:"timeline_items"`
Sources []HeritageSource `json:"sources"`
Title *HeritageTitle `json:"title"`
TimelineItems []TimelineItem `json:"timeline_items"`
Sources []HeritageSource `json:"sources"`
}

View File

@ -3,6 +3,8 @@ package domain
import (
"context"
"time"
"gorm.io/gorm"
)
// Localizable defines an interface for entities that support localization
@ -11,6 +13,33 @@ type Localizable interface {
GetEntityID() string
}
// EntityLoadOptions contains options for loading entities for localization
type EntityLoadOptions struct {
IncludeAllSites bool
}
// EntityHandler defines the interface for handling entity-specific operations for localization
// This interface is implemented by handlers in the localization/handlers package
type EntityHandler[T any] interface {
// GetEntityID returns the unique identifier for an entity
GetEntityID(entity T) string
// GetFieldValue returns the value of a specific field from an entity
GetFieldValue(entity T, field string) string
// GetLocalizableFields returns the list of fields that can be localized
GetLocalizableFields() []string
// LoadEntities loads entities from the database with optional filtering
LoadEntities(db *gorm.DB, options EntityLoadOptions) ([]T, error)
// BuildFieldQuery builds a GORM query for finding entities with specific field values
BuildFieldQuery(db *gorm.DB, field, value string) *gorm.DB
// GetEntityType returns the entity type string
GetEntityType() string
}
// Localization represents localized strings for any entity and field
type Localization struct {
ID string `gorm:"primaryKey;type:text"`
@ -35,6 +64,13 @@ type LocalizationService interface {
GetLocalizedValue(entityType, entityID, field, locale string) (string, error)
SetLocalizedValue(entityType, entityID, field, locale, value string) error
GetAllLocalizedValues(entityType, entityID string) (map[string]map[string]string, error) // field -> locale -> value
GetLocalizedEntity(entityType, entityID, locale string) (map[string]string, error)
GetSupportedLocalesForEntity(entityType, entityID string) ([]string, error)
DeleteLocalizedValue(entityType, entityID, field, locale string) error
BulkSetLocalizedValues(entityType, entityID string, values map[string]map[string]string) error
GetAllLocales() ([]string, error)
SearchLocalizations(query, locale string, limit int) ([]*Localization, error)
ApplyLocalizationToEntity(entity Localizable, locale string) error
}
// Helper methods for models to implement Localizable
@ -82,10 +118,45 @@ func SetLocalizedName(entity Localizable, locale, value string, service Localiza
return service.SetLocalizedValue(entity.GetEntityType(), entity.GetEntityID(), "name", locale, value)
}
// TranslationStats represents overall translation statistics
type TranslationStats struct {
TotalEntities int
TotalFields int
TotalTranslations int
EntityStats map[string]*EntityTranslationStats
}
// EntityTranslationStats represents translation statistics for a specific entity type
type EntityTranslationStats struct {
EntityType string
TotalEntities int
TotalFields int
RussianCount int
EnglishCount int
TatarCount int
TotalTranslations int
}
type LocalizationRepository interface {
Create(ctx context.Context, loc *Localization) error
GetByEntityAndField(ctx context.Context, entityType, entityID, field, locale string) (*Localization, error)
GetAllByEntity(ctx context.Context, entityType, entityID string) ([]*Localization, error)
Update(ctx context.Context, loc *Localization) error
Delete(ctx context.Context, id string) error
GetByEntityTypeAndLocale(ctx context.Context, entityType, locale string) ([]*Localization, error)
GetAllLocales(ctx context.Context) ([]string, error)
GetSupportedLocalesForEntity(ctx context.Context, entityType, entityID string) ([]string, error)
BulkCreate(ctx context.Context, localizations []*Localization) error
BulkDelete(ctx context.Context, ids []string) error
SearchLocalizations(ctx context.Context, query string, locale string, limit int) ([]*Localization, error)
GetTranslationReuseCandidates(ctx context.Context, entityType, field, locale string) ([]ReuseCandidate, error)
GetEntitiesNeedingTranslation(ctx context.Context, entityType, field, targetLocale string, limit int) ([]string, error)
FindExistingTranslationByRussianText(ctx context.Context, entityType, field, targetLocale, russianText string) (string, error)
GetTranslationCountsByEntity(ctx context.Context) (map[string]map[string]int, error)
}
// ReuseCandidate represents a piece of Russian text that appears in multiple entities
type ReuseCandidate struct {
RussianValue string
EntityCount int
}

View File

@ -24,6 +24,9 @@ func AutoMigrate(db *gorm.DB) error {
&Product{},
&Service{},
&ServiceNeed{},
&TimelineItem{}, // Add timeline items table
&HeritageTitle{}, // Add heritage title table
&HeritageSource{}, // Add heritage sources table
); err != nil {
return err
}
@ -49,7 +52,10 @@ func RunPostGISMigrations(db *gorm.DB) error {
return fmt.Errorf("failed to check PostGIS extension: %v", err)
}
if !postgisEnabled {
return fmt.Errorf("PostGIS extension is not enabled in this database")
// PostGIS is not enabled - this is not an error in test environments
// Just skip PostGIS migrations gracefully
fmt.Printf("PostGIS not enabled, skipping PostGIS migrations\n")
return nil
}
// Verify PostGIS functions are available
@ -150,6 +156,26 @@ func RunPostGISMigrations(db *gorm.DB) error {
return nil
}
// cleanupGeometryColumns removes geometry-related columns and indexes when PostGIS is not available
func cleanupGeometryColumns(db *gorm.DB) error {
// Drop geometry column if it exists
if err := db.Exec("ALTER TABLE sites DROP COLUMN IF EXISTS location_geometry").Error; err != nil {
return fmt.Errorf("failed to drop geometry column: %v", err)
}
// Drop spatial indexes
if err := db.Exec("DROP INDEX IF EXISTS idx_site_geometry").Error; err != nil {
return fmt.Errorf("failed to drop geometry index: %v", err)
}
// Drop constraints
if err := db.Exec("ALTER TABLE sites DROP CONSTRAINT IF EXISTS check_location_geometry").Error; err != nil {
return fmt.Errorf("failed to drop geometry constraint: %v", err)
}
return nil
}
// FixJSONBColumns fixes JSONB column conversion issues that GORM can't handle automatically
func FixJSONBColumns(db *gorm.DB) error {
// Fix certifications column - remove default, convert to JSONB, add default back

View File

@ -25,7 +25,7 @@ func unmarshalStringSlice(data datatypes.JSON) []string {
func (o Organization) MarshalJSON() ([]byte, error) {
type Alias Organization
return json.Marshal(&struct {
GalleryImages []string `json:"GalleryImages"`
GalleryImages []string `json:"GalleryImages"`
Certifications []string `json:"Certifications"`
BusinessFocus []string `json:"BusinessFocus"`
TechnicalExpertise []string `json:"TechnicalExpertise"`
@ -35,7 +35,7 @@ func (o Organization) MarshalJSON() ([]byte, error) {
ExistingSymbioticRelationships []string `json:"ExistingSymbioticRelationships"`
*Alias
}{
GalleryImages: unmarshalStringSlice(o.GalleryImages),
GalleryImages: unmarshalStringSlice(o.GalleryImages),
Certifications: unmarshalStringSlice(o.Certifications),
BusinessFocus: unmarshalStringSlice(o.BusinessFocus),
TechnicalExpertise: unmarshalStringSlice(o.TechnicalExpertise),
@ -47,26 +47,450 @@ func (o Organization) MarshalJSON() ([]byte, error) {
})
}
// OrganizationSubtype defines the category of organization
// OrganizationSubtype defines the specific business type of organization
type OrganizationSubtype string
// OrganizationSector defines the broader economic sector of organization
type OrganizationSector string
// Sector constants
const (
SectorHealthcare OrganizationSector = "healthcare"
SectorReligious OrganizationSector = "religious"
SectorFoodBeverage OrganizationSector = "food_beverage"
SectorRetail OrganizationSector = "retail"
SectorServices OrganizationSector = "services"
SectorEducation OrganizationSector = "education"
SectorBeautyWellness OrganizationSector = "beauty_wellness"
SectorAutomotive OrganizationSector = "automotive"
SectorHospitality OrganizationSector = "hospitality"
SectorManufacturing OrganizationSector = "manufacturing"
SectorEnergy OrganizationSector = "energy"
SectorConstruction OrganizationSector = "construction"
SectorFinancial OrganizationSector = "financial"
SectorEntertainment OrganizationSector = "entertainment"
SectorFurniture OrganizationSector = "furniture"
SectorSports OrganizationSector = "sports"
SectorAgriculture OrganizationSector = "agriculture"
SectorTechnology OrganizationSector = "technology"
SectorInfrastructure OrganizationSector = "infrastructure"
SectorGovernment OrganizationSector = "government"
SectorOther OrganizationSector = "other"
)
// Healthcare subtypes
const (
SubtypePharmacy OrganizationSubtype = "pharmacy"
SubtypeClinic OrganizationSubtype = "clinic"
SubtypeDentist OrganizationSubtype = "dentist"
SubtypeHospital OrganizationSubtype = "hospital"
SubtypeLaboratory OrganizationSubtype = "laboratory"
SubtypeTherapy OrganizationSubtype = "therapy"
SubtypeOptician OrganizationSubtype = "optician"
SubtypeVeterinary OrganizationSubtype = "veterinary"
)
// Religious subtypes
const (
SubtypeMosque OrganizationSubtype = "mosque"
SubtypeTemple OrganizationSubtype = "temple"
SubtypeChurch OrganizationSubtype = "church"
SubtypeSynagogue OrganizationSubtype = "synagogue"
SubtypeGurudwara OrganizationSubtype = "gurudwara"
SubtypeBuddhistCenter OrganizationSubtype = "buddhist_center"
SubtypeReligiousSchool OrganizationSubtype = "religious_school"
)
// Food & Beverage subtypes
const (
SubtypeRestaurant OrganizationSubtype = "restaurant"
SubtypeCafe OrganizationSubtype = "cafe"
SubtypeFastFood OrganizationSubtype = "fast_food"
SubtypeBakery OrganizationSubtype = "bakery"
SubtypeBar OrganizationSubtype = "bar"
SubtypeCatering OrganizationSubtype = "catering"
SubtypeFoodTruck OrganizationSubtype = "food_truck"
)
// Retail subtypes
const (
SubtypeGroceryStore OrganizationSubtype = "grocery_store"
SubtypeDepartmentStore OrganizationSubtype = "department_store"
SubtypeBoutique OrganizationSubtype = "boutique"
SubtypeElectronicsStore OrganizationSubtype = "electronics_store"
SubtypeBookstore OrganizationSubtype = "bookstore"
SubtypeConvenienceStore OrganizationSubtype = "convenience_store"
SubtypeMarket OrganizationSubtype = "market"
)
// Services subtypes
const (
SubtypeLawyer OrganizationSubtype = "lawyer"
SubtypeAccountant OrganizationSubtype = "accountant"
SubtypeConsultant OrganizationSubtype = "consultant"
SubtypeITServices OrganizationSubtype = "it_services"
SubtypeCleaning OrganizationSubtype = "cleaning"
SubtypeRepairService OrganizationSubtype = "repair_service"
SubtypeTutoring OrganizationSubtype = "tutoring"
)
// Education subtypes
const (
SubtypeSchool OrganizationSubtype = "school"
SubtypeCollege OrganizationSubtype = "college"
SubtypeUniversity OrganizationSubtype = "university"
SubtypeKindergarten OrganizationSubtype = "kindergarten"
SubtypeTrainingCenter OrganizationSubtype = "training_center"
)
// Personal services subtypes
const (
SubtypeSalon OrganizationSubtype = "salon"
SubtypeSpa OrganizationSubtype = "spa"
SubtypeBarber OrganizationSubtype = "barber"
SubtypeGym OrganizationSubtype = "gym"
SubtypeNailSalon OrganizationSubtype = "nail_salon"
SubtypeMassageTherapy OrganizationSubtype = "massage_therapy"
SubtypeBeautySupply OrganizationSubtype = "beauty_supply"
SubtypePersonalServices OrganizationSubtype = "personal_services"
)
// Automotive & Transportation subtypes
const (
SubtypeCarDealership OrganizationSubtype = "car_dealership"
SubtypeAutoRepair OrganizationSubtype = "auto_repair"
SubtypeCarWash OrganizationSubtype = "car_wash"
SubtypeAutoParts OrganizationSubtype = "auto_parts"
SubtypeTireShop OrganizationSubtype = "tire_shop"
SubtypeMotorcycleShop OrganizationSubtype = "motorcycle_shop"
SubtypeTaxi OrganizationSubtype = "taxi"
SubtypeBusStation OrganizationSubtype = "bus_station"
SubtypeDelivery OrganizationSubtype = "delivery"
SubtypeTransportation OrganizationSubtype = "transportation"
)
// Hospitality subtypes
const (
SubtypeHotel OrganizationSubtype = "hotel"
SubtypeHostel OrganizationSubtype = "hostel"
)
// Manufacturing & Industrial subtypes
const (
SubtypeFactory OrganizationSubtype = "factory"
SubtypeWorkshop OrganizationSubtype = "workshop"
SubtypeManufacturing OrganizationSubtype = "manufacturing"
)
// Energy subtypes
const (
SubtypePowerPlant OrganizationSubtype = "power_plant"
SubtypeFuelStation OrganizationSubtype = "fuel_station"
SubtypeEnergy OrganizationSubtype = "energy"
)
// Construction subtypes
const (
SubtypeConstructionContractor OrganizationSubtype = "construction_contractor"
SubtypeConstruction OrganizationSubtype = "construction"
)
// Financial subtypes
const (
SubtypeBank OrganizationSubtype = "bank"
SubtypeInsurance OrganizationSubtype = "insurance"
SubtypeFinancial OrganizationSubtype = "financial"
)
// Entertainment & Cultural subtypes
const (
SubtypeTheater OrganizationSubtype = "theater"
SubtypeCinema OrganizationSubtype = "cinema"
SubtypeMuseum OrganizationSubtype = "museum"
SubtypeConcertHall OrganizationSubtype = "concert_hall"
SubtypeGamingCenter OrganizationSubtype = "gaming_center"
SubtypeNightclub OrganizationSubtype = "nightclub"
SubtypeCultural OrganizationSubtype = "cultural"
)
// Furniture subtypes
const (
SubtypeFurnitureStore OrganizationSubtype = "furniture_store"
SubtypeFurnitureManufacturer OrganizationSubtype = "furniture_manufacturer"
SubtypeInteriorDesign OrganizationSubtype = "interior_design"
SubtypeFurnitureRepair OrganizationSubtype = "furniture_repair"
)
// Sports subtypes
const (
SubtypeSportsClub OrganizationSubtype = "sports_club"
SubtypeStadium OrganizationSubtype = "stadium"
SubtypeSportsEquipment OrganizationSubtype = "sports_equipment"
)
// Agriculture subtypes
const (
SubtypeFarm OrganizationSubtype = "farm"
SubtypeAgriculturalSupplier OrganizationSubtype = "agricultural_supplier"
SubtypeGreenhouse OrganizationSubtype = "greenhouse"
SubtypeLivestock OrganizationSubtype = "livestock"
)
// Technology subtypes
const (
SubtypeSoftwareCompany OrganizationSubtype = "software_company"
SubtypeHardwareCompany OrganizationSubtype = "hardware_company"
SubtypeTechSupport OrganizationSubtype = "tech_support"
SubtypeWebDevelopment OrganizationSubtype = "web_development"
SubtypeTelecommunications OrganizationSubtype = "telecommunications"
)
// Infrastructure subtypes
const (
SubtypePowerStation OrganizationSubtype = "power_station"
SubtypeWaterTreatment OrganizationSubtype = "water_treatment"
SubtypeWasteManagement OrganizationSubtype = "waste_management"
)
// Legacy/Generic subtypes (kept for backward compatibility and migration)
const (
SubtypeCommercial OrganizationSubtype = "commercial"
SubtypeCultural OrganizationSubtype = "cultural"
SubtypeGovernment OrganizationSubtype = "government"
SubtypeReligious OrganizationSubtype = "religious"
SubtypeEducational OrganizationSubtype = "educational"
SubtypeInfrastructure OrganizationSubtype = "infrastructure"
SubtypeHealthcare OrganizationSubtype = "healthcare"
SubtypeOther OrganizationSubtype = "other"
SubtypeNeedsReview OrganizationSubtype = "needs_review" // Temporary for migration
)
// IsValidSubtype checks if a subtype value is valid
func IsValidSubtype(subtype OrganizationSubtype) bool {
validSubtypes := map[OrganizationSubtype]bool{
// Healthcare
SubtypePharmacy: true, SubtypeClinic: true, SubtypeDentist: true, SubtypeHospital: true,
SubtypeLaboratory: true, SubtypeTherapy: true, SubtypeOptician: true, SubtypeVeterinary: true,
// Religious
SubtypeMosque: true, SubtypeTemple: true, SubtypeChurch: true, SubtypeSynagogue: true,
SubtypeGurudwara: true, SubtypeBuddhistCenter: true, SubtypeReligiousSchool: true,
// Food & Beverage
SubtypeRestaurant: true, SubtypeCafe: true, SubtypeFastFood: true, SubtypeBakery: true,
SubtypeBar: true, SubtypeCatering: true, SubtypeFoodTruck: true,
// Retail
SubtypeGroceryStore: true, SubtypeDepartmentStore: true, SubtypeBoutique: true,
SubtypeElectronicsStore: true, SubtypeBookstore: true, SubtypeConvenienceStore: true, SubtypeMarket: true,
// Services
SubtypeLawyer: true, SubtypeAccountant: true, SubtypeConsultant: true, SubtypeITServices: true,
SubtypeCleaning: true, SubtypeRepairService: true, SubtypeTutoring: true,
// Education
SubtypeSchool: true, SubtypeCollege: true, SubtypeUniversity: true,
SubtypeKindergarten: true, SubtypeTrainingCenter: true,
// Personal services
SubtypeSalon: true, SubtypeSpa: true, SubtypeBarber: true, SubtypeGym: true,
SubtypeNailSalon: true, SubtypeMassageTherapy: true, SubtypeBeautySupply: true, SubtypePersonalServices: true,
// Automotive & Transportation
SubtypeCarDealership: true, SubtypeAutoRepair: true, SubtypeCarWash: true,
SubtypeAutoParts: true, SubtypeTireShop: true, SubtypeMotorcycleShop: true,
SubtypeTaxi: true, SubtypeBusStation: true, SubtypeDelivery: true, SubtypeTransportation: true,
// Hospitality
SubtypeHotel: true, SubtypeHostel: true,
// Manufacturing & Industrial
SubtypeFactory: true, SubtypeWorkshop: true, SubtypeManufacturing: true,
// Energy
SubtypePowerPlant: true, SubtypeFuelStation: true, SubtypeEnergy: true,
// Construction
SubtypeConstructionContractor: true, SubtypeConstruction: true,
// Financial
SubtypeBank: true, SubtypeInsurance: true, SubtypeFinancial: true,
// Entertainment & Cultural
SubtypeTheater: true, SubtypeCinema: true, SubtypeMuseum: true,
SubtypeConcertHall: true, SubtypeGamingCenter: true, SubtypeNightclub: true, SubtypeCultural: true,
// Furniture
SubtypeFurnitureStore: true, SubtypeFurnitureManufacturer: true,
SubtypeInteriorDesign: true, SubtypeFurnitureRepair: true,
// Sports
SubtypeSportsClub: true, SubtypeStadium: true, SubtypeSportsEquipment: true,
// Agriculture
SubtypeFarm: true, SubtypeAgriculturalSupplier: true, SubtypeGreenhouse: true, SubtypeLivestock: true,
// Technology
SubtypeSoftwareCompany: true, SubtypeHardwareCompany: true, SubtypeTechSupport: true,
SubtypeWebDevelopment: true, SubtypeTelecommunications: true,
// Infrastructure
SubtypePowerStation: true, SubtypeWaterTreatment: true, SubtypeWasteManagement: true,
// Legacy/Generic
SubtypeCommercial: true, SubtypeGovernment: true, SubtypeReligious: true,
SubtypeEducational: true, SubtypeInfrastructure: true, SubtypeHealthcare: true, SubtypeOther: true,
// Temporary
SubtypeNeedsReview: true,
}
return validSubtypes[subtype]
}
// GetAllSubtypes returns all valid organization subtypes dynamically
func GetAllSubtypes() []OrganizationSubtype {
return []OrganizationSubtype{
// Healthcare
SubtypePharmacy, SubtypeClinic, SubtypeDentist, SubtypeHospital,
SubtypeLaboratory, SubtypeTherapy, SubtypeOptician, SubtypeVeterinary,
// Religious
SubtypeMosque, SubtypeTemple, SubtypeChurch, SubtypeSynagogue,
SubtypeGurudwara, SubtypeBuddhistCenter, SubtypeReligiousSchool,
// Food & Beverage
SubtypeRestaurant, SubtypeCafe, SubtypeFastFood, SubtypeBakery,
SubtypeBar, SubtypeCatering, SubtypeFoodTruck,
// Retail
SubtypeGroceryStore, SubtypeDepartmentStore, SubtypeBoutique,
SubtypeElectronicsStore, SubtypeBookstore, SubtypeConvenienceStore, SubtypeMarket,
// Services
SubtypeLawyer, SubtypeAccountant, SubtypeConsultant, SubtypeITServices,
SubtypeCleaning, SubtypeRepairService, SubtypeTutoring,
// Education
SubtypeSchool, SubtypeCollege, SubtypeUniversity,
SubtypeKindergarten, SubtypeTrainingCenter,
// Personal services
SubtypeSalon, SubtypeSpa, SubtypeBarber, SubtypeGym,
SubtypeNailSalon, SubtypeMassageTherapy, SubtypeBeautySupply, SubtypePersonalServices,
// Automotive & Transportation
SubtypeCarDealership, SubtypeAutoRepair, SubtypeCarWash,
SubtypeAutoParts, SubtypeTireShop, SubtypeMotorcycleShop,
SubtypeTaxi, SubtypeBusStation, SubtypeDelivery, SubtypeTransportation,
// Hospitality
SubtypeHotel, SubtypeHostel,
// Manufacturing & Industrial
SubtypeFactory, SubtypeWorkshop, SubtypeManufacturing,
// Energy
SubtypePowerPlant, SubtypeFuelStation, SubtypeEnergy,
// Construction
SubtypeConstructionContractor, SubtypeConstruction,
// Financial
SubtypeBank, SubtypeInsurance, SubtypeFinancial,
// Entertainment & Cultural
SubtypeTheater, SubtypeCinema, SubtypeMuseum,
SubtypeConcertHall, SubtypeGamingCenter, SubtypeNightclub, SubtypeCultural,
// Furniture
SubtypeFurnitureStore, SubtypeFurnitureManufacturer,
SubtypeInteriorDesign, SubtypeFurnitureRepair,
// Sports
SubtypeSportsClub, SubtypeStadium, SubtypeSportsEquipment,
// Agriculture
SubtypeFarm, SubtypeAgriculturalSupplier, SubtypeGreenhouse, SubtypeLivestock,
// Technology
SubtypeSoftwareCompany, SubtypeHardwareCompany, SubtypeTechSupport,
SubtypeWebDevelopment, SubtypeTelecommunications,
// Infrastructure
SubtypePowerStation, SubtypeWaterTreatment, SubtypeWasteManagement,
// Legacy/Generic
SubtypeCommercial, SubtypeGovernment, SubtypeReligious,
SubtypeEducational, SubtypeInfrastructure, SubtypeHealthcare, SubtypeOther,
}
}
// GetSubtypesBySector returns subtypes that are commonly associated with a given sector
// This is a helper function for filtering/validation, not a strict requirement
func GetSubtypesBySector(sector OrganizationSector) []OrganizationSubtype {
switch sector {
case SectorHealthcare:
return []OrganizationSubtype{
SubtypePharmacy, SubtypeClinic, SubtypeDentist, SubtypeHospital,
SubtypeLaboratory, SubtypeTherapy, SubtypeOptician, SubtypeVeterinary,
}
case SectorReligious:
return []OrganizationSubtype{
SubtypeMosque, SubtypeTemple, SubtypeChurch, SubtypeSynagogue,
SubtypeGurudwara, SubtypeBuddhistCenter, SubtypeReligiousSchool,
}
case SectorFoodBeverage:
return []OrganizationSubtype{
SubtypeRestaurant, SubtypeCafe, SubtypeFastFood, SubtypeBakery,
SubtypeBar, SubtypeCatering, SubtypeFoodTruck,
}
case SectorRetail:
return []OrganizationSubtype{
SubtypeGroceryStore, SubtypeDepartmentStore, SubtypeBoutique,
SubtypeElectronicsStore, SubtypeBookstore, SubtypeConvenienceStore, SubtypeMarket,
}
case SectorServices:
return []OrganizationSubtype{
SubtypeLawyer, SubtypeAccountant, SubtypeConsultant, SubtypeITServices,
SubtypeCleaning, SubtypeRepairService, SubtypeTutoring,
}
case SectorEducation:
return []OrganizationSubtype{
SubtypeSchool, SubtypeCollege, SubtypeUniversity,
SubtypeKindergarten, SubtypeTrainingCenter,
}
case SectorBeautyWellness:
return []OrganizationSubtype{
SubtypeSalon, SubtypeSpa, SubtypeBarber, SubtypeGym,
SubtypeNailSalon, SubtypeMassageTherapy, SubtypeBeautySupply, SubtypePersonalServices,
}
case SectorAutomotive:
return []OrganizationSubtype{
SubtypeCarDealership, SubtypeAutoRepair, SubtypeCarWash,
SubtypeAutoParts, SubtypeTireShop, SubtypeMotorcycleShop,
SubtypeTaxi, SubtypeBusStation, SubtypeDelivery, SubtypeTransportation,
}
case SectorHospitality:
return []OrganizationSubtype{
SubtypeHotel, SubtypeHostel,
}
case SectorManufacturing:
return []OrganizationSubtype{
SubtypeFactory, SubtypeWorkshop, SubtypeManufacturing,
}
case SectorEnergy:
return []OrganizationSubtype{
SubtypePowerPlant, SubtypeFuelStation, SubtypeEnergy,
}
case SectorConstruction:
return []OrganizationSubtype{
SubtypeConstructionContractor, SubtypeConstruction,
}
case SectorFinancial:
return []OrganizationSubtype{
SubtypeBank, SubtypeInsurance, SubtypeFinancial,
}
case SectorEntertainment:
return []OrganizationSubtype{
SubtypeTheater, SubtypeCinema, SubtypeMuseum,
SubtypeConcertHall, SubtypeGamingCenter, SubtypeNightclub, SubtypeCultural,
}
case SectorFurniture:
return []OrganizationSubtype{
SubtypeFurnitureStore, SubtypeFurnitureManufacturer,
SubtypeInteriorDesign, SubtypeFurnitureRepair,
}
case SectorSports:
return []OrganizationSubtype{
SubtypeSportsClub, SubtypeStadium, SubtypeSportsEquipment,
}
case SectorAgriculture:
return []OrganizationSubtype{
SubtypeFarm, SubtypeAgriculturalSupplier, SubtypeGreenhouse, SubtypeLivestock,
}
case SectorTechnology:
return []OrganizationSubtype{
SubtypeSoftwareCompany, SubtypeHardwareCompany, SubtypeTechSupport,
SubtypeWebDevelopment, SubtypeTelecommunications,
}
default:
// Return all subtypes for unknown sectors or generic sectors
return GetAllSubtypes()
}
}
// SectorStat represents sector statistics
type SectorStat struct {
Sector string `json:"sector"`
Count int `json:"count"`
}
// Image represents an uploaded image
type Image struct {
URL string `json:"url"`
Path string `json:"path"`
}
// LegalForm represents the legal structure of a business organization
type LegalForm string
@ -129,7 +553,7 @@ type Organization struct {
// Subtype categorization (commercial, healthcare, educational, etc.)
Subtype OrganizationSubtype `gorm:"not null;type:varchar(50);index" json:"Subtype"`
Sector string `gorm:"type:text;index" json:"Sector"` // NACE code or category
Sector OrganizationSector `gorm:"type:varchar(50);index" json:"Sector"` // NACE code or category
// Basic information
Description string `gorm:"type:text" json:"Description"`
@ -159,9 +583,9 @@ type Organization struct {
ManagementSystems datatypes.JSON `gorm:"default:'[]'" json:"-"` // []string
// Products and Services
SellsProducts datatypes.JSON `gorm:"default:'[]'" json:"SellsProducts"` // []Product
SellsProducts datatypes.JSON `gorm:"default:'[]'" json:"SellsProducts"` // []Product
OffersServices datatypes.JSON `gorm:"default:'[]'" json:"OffersServices"` // []Service
NeedsServices datatypes.JSON `gorm:"default:'[]'" json:"NeedsServices"` // []ServiceNeed
NeedsServices datatypes.JSON `gorm:"default:'[]'" json:"NeedsServices"` // []ServiceNeed
// Historical/cultural building fields (for non-commercial subtypes)
YearBuilt string `gorm:"type:text" json:"YearBuilt"`
@ -197,7 +621,6 @@ type Organization struct {
ResourceFlows []ResourceFlow `gorm:"foreignKey:OrganizationID" json:"ResourceFlows,omitempty"`
}
// TableName specifies the table name for GORM
func (Organization) TableName() string {
return "organizations"
@ -233,11 +656,12 @@ type OrganizationRepository interface {
GetAll(ctx context.Context) ([]*Organization, error)
Update(ctx context.Context, org *Organization) error
Delete(ctx context.Context, id string) error
GetBySector(ctx context.Context, sector string) ([]*Organization, error)
GetBySector(ctx context.Context, sector OrganizationSector) ([]*Organization, error)
GetBySubtype(ctx context.Context, subtype OrganizationSubtype) ([]*Organization, error)
GetWithinRadius(ctx context.Context, lat, lng, radiusKm float64) ([]*Organization, error)
GetByCertification(ctx context.Context, cert string) ([]*Organization, error)
Search(ctx context.Context, query string, limit int) ([]*Organization, error)
SearchSuggestions(ctx context.Context, query string, limit int) ([]string, error)
GetSectorStats(ctx context.Context, limit int) ([]SectorStat, error)
GetResourceFlowsByTypeAndDirection(ctx context.Context, resourceType string, direction string) ([]*ResourceFlow, error)
}

View File

@ -15,8 +15,9 @@ type Point struct {
}
// GormDataType specifies the database column type for GORM
// We use text type for GORM migrations, and handle PostGIS geometry conversion in Value/Scan
func (Point) GormDataType() string {
return "geometry(Point,4326)"
return "text"
}
// Scan implements sql.Scanner interface to read PostGIS geometry from database
@ -26,59 +27,43 @@ func (p *Point) Scan(value interface{}) error {
return nil
}
var bytes []byte
var str string
switch v := value.(type) {
case []byte:
bytes = v
str = string(v)
case string:
bytes = []byte(v)
str = v
default:
return fmt.Errorf("cannot scan %T into Point", value)
}
// PostGIS returns geometry in EWKB format or WKT format
// Try to parse as WKT first (common format)
str := string(bytes)
// Handle EWKB format (starts with specific bytes)
if len(bytes) > 0 && bytes[0] == 0x00 {
// This is EWKB format, we need to decode it
// For now, we'll extract coordinates from a common pattern
// In production, consider using a library like github.com/twpayne/go-geom
return fmt.Errorf("EWKB format not yet supported, use WKT")
}
// Try to parse WKT format: POINT(lng lat) or POINT(lat lng)
// PostGIS typically returns: "0101000020E6100000..." (EWKB hex) or WKT
// For simplicity, we'll handle the case where we have lat/lng separately
// and let the database handle the conversion
// If it's a hex string (EWKB), we can't easily parse it here
// The best approach is to use ST_AsText in queries or handle it at DB level
if strings.HasPrefix(str, "0101") || len(str) > 50 {
// Likely EWKB hex format - we'll need to query as text or use ST_AsText
// Handle empty or invalid values
if str == "" || str == "POINT EMPTY" {
p.Valid = false
return nil
}
// Try WKT format: POINT(lng lat)
if strings.HasPrefix(str, "POINT") {
// Try WKT format: POINT(lng lat) or POINT(lat lng)
// This works whether the data comes from PostGIS geometry or plain text storage
if strings.HasPrefix(str, "POINT(") && strings.HasSuffix(str, ")") {
// Extract coordinates from POINT(lng lat) format
coords := strings.TrimPrefix(str, "POINT(")
coords = strings.TrimSuffix(coords, ")")
var lng, lat float64
_, err := fmt.Sscanf(str, "POINT(%f %f)", &lng, &lat)
_, err := fmt.Sscanf(coords, "%f %f", &lng, &lat)
if err != nil {
// Try reverse order: POINT(lat lng)
_, err = fmt.Sscanf(str, "POINT(%f %f)", &lat, &lng)
if err != nil {
p.Valid = false
return nil
}
p.Valid = false
return nil
}
p.Longitude = lng
p.Latitude = lat
p.Valid = true
return nil
}
// If we can't parse it, mark as invalid
p.Valid = false
return nil
}
@ -88,9 +73,10 @@ func (p Point) Value() (driver.Value, error) {
if !p.Valid {
return nil, nil
}
// Return WKT format for PostGIS
// PostGIS will convert this to the proper geometry type
return fmt.Sprintf("SRID=4326;POINT(%f %f)", p.Longitude, p.Latitude), nil
// Return WKT format that works with or without PostGIS
// When PostGIS is available, it will be stored as geometry
// When PostGIS is not available, it will be stored as text
return fmt.Sprintf("POINT(%f %f)", p.Longitude, p.Latitude), nil
}
// String returns the WKT representation of the point

View File

@ -5,6 +5,7 @@ import (
"fmt"
"time"
"github.com/lib/pq"
"gorm.io/datatypes"
)
@ -38,10 +39,23 @@ type Product struct {
OrganizationID string `gorm:"not null;type:text;index"`
Organization *Organization `gorm:"foreignKey:OrganizationID"`
// Site information (for location-based matching)
SiteID *string `gorm:"type:text;index"`
Site *Site `gorm:"foreignKey:SiteID"`
// Location (PostGIS Point for spatial queries)
Location Point `gorm:"type:geometry(Point,4326)"` // PostGIS geometry point
// Discovery and search fields
SearchKeywords string `gorm:"type:text"` // Full-text search keywords
Tags pq.StringArray `gorm:"type:text[]"` // Searchable tags array
AvailabilityStatus string `gorm:"type:varchar(20);default:'available';index"` // available, limited, out_of_stock
Images pq.StringArray `gorm:"type:text[]"` // Array of image URLs
// Additional metadata
Capacity string `gorm:"type:text"` // Production capacity info
Specifications datatypes.JSON `gorm:"type:jsonb;default:'{}'"` // Technical specifications
Availability string `gorm:"type:text"` // Availability status
Availability string `gorm:"type:text"` // Availability status (legacy field, kept for compatibility)
Sources datatypes.JSON `gorm:"type:jsonb"` // Data sources
// Timestamps
@ -65,6 +79,10 @@ type ProductRepository interface {
Update(ctx context.Context, product *Product) error
Delete(ctx context.Context, id string) error
GetAll(ctx context.Context) ([]*Product, error)
// Discovery methods
SearchWithLocation(ctx context.Context, query string, location *Point, radiusKm float64) ([]*Product, error)
GetBySite(ctx context.Context, siteID string) ([]*Product, error)
GetNearby(ctx context.Context, lat, lng, radiusKm float64) ([]*Product, error)
}
// Validate performs business rule validation

View File

@ -293,6 +293,7 @@ func (ResourceFlow) TableName() string {
type ResourceFlowRepository interface {
Create(ctx context.Context, rf *ResourceFlow) error
GetByID(ctx context.Context, id string) (*ResourceFlow, error)
GetAll(ctx context.Context) ([]*ResourceFlow, error)
GetBySiteID(ctx context.Context, siteID string) ([]*ResourceFlow, error)
GetByOrganizationID(ctx context.Context, organizationID string) ([]*ResourceFlow, error)
GetByTypeAndDirection(ctx context.Context, resType ResourceType, direction ResourceDirection) ([]*ResourceFlow, error)

View File

@ -5,6 +5,7 @@ import (
"fmt"
"time"
"github.com/lib/pq"
"gorm.io/datatypes"
)
@ -37,11 +38,24 @@ type Service struct {
OrganizationID string `gorm:"not null;type:text;index"`
Organization *Organization `gorm:"foreignKey:OrganizationID"`
// Site information (for location-based matching)
SiteID *string `gorm:"type:text;index"`
Site *Site `gorm:"foreignKey:SiteID"`
// Service location (PostGIS Point for spatial queries)
ServiceLocation Point `gorm:"type:geometry(Point,4326)"` // PostGIS geometry point
// Discovery and search fields
SearchKeywords string `gorm:"type:text"` // Full-text search keywords
Tags pq.StringArray `gorm:"type:text[]"` // Searchable tags array
AvailabilityStatus string `gorm:"type:varchar(20);default:'available';index"` // available, limited, unavailable
AvailabilitySchedule datatypes.JSON `gorm:"type:jsonb"` // Time-based availability schedule
// Additional metadata
ResponseTime string `gorm:"type:text"` // Response time SLA
Warranty string `gorm:"type:text"` // Warranty terms
Specializations datatypes.JSON `gorm:"type:jsonb;default:'[]'"` // []string - specific specializations
Availability string `gorm:"type:text"` // Service availability
Availability string `gorm:"type:text"` // Service availability (legacy field, kept for compatibility)
Sources datatypes.JSON `gorm:"type:jsonb"` // Data sources
// Timestamps
@ -66,6 +80,10 @@ type ServiceRepository interface {
Update(ctx context.Context, service *Service) error
Delete(ctx context.Context, id string) error
GetAll(ctx context.Context) ([]*Service, error)
// Discovery methods
SearchWithLocation(ctx context.Context, query string, location *Point, radiusKm float64) ([]*Service, error)
GetBySite(ctx context.Context, siteID string) ([]*Service, error)
GetNearby(ctx context.Context, lat, lng, radiusKm float64) ([]*Service, error)
}
// Validate performs business rule validation

View File

@ -152,12 +152,12 @@ func (s Site) MarshalJSON() ([]byte, error) {
return json.Marshal(&struct {
AvailableUtilities []string `json:"AvailableUtilities"`
WasteManagement []string `json:"WasteManagement"`
Sources []string `json:"Sources"`
Sources []string `json:"Sources"`
*Alias
}{
AvailableUtilities: unmarshalStringSliceSite(s.AvailableUtilities),
WasteManagement: unmarshalStringSliceSite(s.WasteManagement),
Sources: unmarshalStringSliceSite(s.Sources),
Sources: unmarshalStringSliceSite(s.Sources),
Alias: (*Alias)(&s),
})
}
@ -206,7 +206,7 @@ func (s *Site) AfterCreate(tx *gorm.DB) error {
if s.Latitude != 0 && s.Longitude != 0 {
if err := tx.Exec(`
UPDATE sites
SET location_geometry = ST_SetSRID(ST_MakePoint(?, ?), 4326)
SET location_geometry = ST_SetSRID(ST_MakePoint(?::double precision, ?::double precision), 4326)
WHERE id = ?
`, s.Longitude, s.Latitude, s.ID).Error; err != nil {
// Log error but don't fail the transaction
@ -248,7 +248,7 @@ func (s *Site) AfterUpdate(tx *gorm.DB) error {
if s.Latitude != 0 && s.Longitude != 0 {
if err := tx.Exec(`
UPDATE sites
SET location_geometry = ST_SetSRID(ST_MakePoint(?, ?), 4326)
SET location_geometry = ST_SetSRID(ST_MakePoint(?::double precision, ?::double precision), 4326)
WHERE id = ?
`, s.Longitude, s.Latitude, s.ID).Error; err != nil {
// Log error but don't fail the transaction
@ -268,6 +268,8 @@ type SiteRepository interface {
GetAll(ctx context.Context) ([]*Site, error)
GetBySiteType(ctx context.Context, siteType SiteType) ([]*Site, error)
GetHeritageSites(ctx context.Context, locale string) ([]*Site, error)
// Find records by arbitrary where clause (convenience for service code/tests)
FindWhere(query interface{}, args ...interface{}) ([]*Site, error)
Update(ctx context.Context, site *Site) error
Delete(ctx context.Context, id string) error
}

View File

@ -0,0 +1,200 @@
package domain
import (
"context"
"time"
)
// SubscriptionPlan represents a subscription plan tier
type SubscriptionPlan string
const (
SubscriptionPlanFree SubscriptionPlan = "free"
SubscriptionPlanBasic SubscriptionPlan = "basic"
SubscriptionPlanProfessional SubscriptionPlan = "professional"
SubscriptionPlanEnterprise SubscriptionPlan = "enterprise"
)
// SubscriptionStatus represents the status of a subscription
type SubscriptionStatus string
const (
SubscriptionStatusActive SubscriptionStatus = "active"
SubscriptionStatusCanceled SubscriptionStatus = "canceled"
SubscriptionStatusPastDue SubscriptionStatus = "past_due"
SubscriptionStatusTrialing SubscriptionStatus = "trialing"
SubscriptionStatusExpired SubscriptionStatus = "expired"
SubscriptionStatusNone SubscriptionStatus = "none"
)
// BillingPeriod represents the billing frequency
type BillingPeriod string
const (
BillingPeriodMonthly BillingPeriod = "monthly"
BillingPeriodYearly BillingPeriod = "yearly"
)
// Subscription represents a user's subscription
type Subscription struct {
ID string `gorm:"primaryKey;type:text"`
UserID string `gorm:"type:text;not null;index"`
Plan SubscriptionPlan `gorm:"type:varchar(50);not null;default:'free'"`
Status SubscriptionStatus `gorm:"type:varchar(20);not null;default:'none'"`
BillingPeriod BillingPeriod `gorm:"type:varchar(20);not null;default:'monthly'"`
CurrentPeriodStart time.Time `gorm:"not null"`
CurrentPeriodEnd time.Time `gorm:"not null"`
CancelAtPeriodEnd bool `gorm:"default:false"`
TrialEnd *time.Time `gorm:"index"`
StripeSubscriptionID string `gorm:"type:text;index"`
StripeCustomerID string `gorm:"type:text;index"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
// Associations
User *User `gorm:"foreignKey:UserID"`
}
// TableName specifies the table name for GORM
func (Subscription) TableName() string {
return "subscriptions"
}
// PaymentMethodType represents the type of payment method
type PaymentMethodType string
const (
PaymentMethodTypeCard PaymentMethodType = "card"
PaymentMethodTypeBankAccount PaymentMethodType = "bank_account"
)
// PaymentMethod represents a payment method for a user
type PaymentMethod struct {
ID string `gorm:"primaryKey;type:text"`
UserID string `gorm:"type:text;not null;index"`
Type PaymentMethodType `gorm:"type:varchar(20);not null"`
StripePaymentMethodID string `gorm:"type:text;index"`
Last4 string `gorm:"type:varchar(4)"`
Brand string `gorm:"type:varchar(50)"`
ExpiryMonth *int `gorm:"type:integer"`
ExpiryYear *int `gorm:"type:integer"`
IsDefault bool `gorm:"default:false"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
// Associations
User *User `gorm:"foreignKey:UserID"`
}
// TableName specifies the table name for GORM
func (PaymentMethod) TableName() string {
return "payment_methods"
}
// InvoiceStatus represents the status of an invoice
type InvoiceStatus string
const (
InvoiceStatusDraft InvoiceStatus = "draft"
InvoiceStatusOpen InvoiceStatus = "open"
InvoiceStatusPaid InvoiceStatus = "paid"
InvoiceStatusVoid InvoiceStatus = "void"
InvoiceStatusUncollectible InvoiceStatus = "uncollectible"
)
// Invoice represents a subscription invoice
type Invoice struct {
ID string `gorm:"primaryKey;type:text"`
UserID string `gorm:"type:text;not null;index"`
SubscriptionID string `gorm:"type:text;not null;index"`
StripeInvoiceID string `gorm:"type:text;index"`
Status InvoiceStatus `gorm:"type:varchar(20);not null;default:'draft'"`
Amount int64 `gorm:"type:bigint;not null"` // Amount in cents
Currency string `gorm:"type:varchar(3);default:'USD'"`
PeriodStart time.Time `gorm:"not null"`
PeriodEnd time.Time `gorm:"not null"`
PaidAt *time.Time `gorm:"index"`
DueDate time.Time `gorm:"not null"`
InvoicePDF string `gorm:"type:text"` // URL to invoice PDF
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
// Associations
User *User `gorm:"foreignKey:UserID"`
Subscription *Subscription `gorm:"foreignKey:SubscriptionID"`
}
// TableName specifies the table name for GORM
func (Invoice) TableName() string {
return "invoices"
}
// UsageLimitType represents the type of usage limit
type UsageLimitType string
const (
UsageLimitTypeOrganizations UsageLimitType = "organizations"
UsageLimitTypeUsers UsageLimitType = "users"
UsageLimitTypeStorage UsageLimitType = "storage"
UsageLimitTypeAPICalls UsageLimitType = "api_calls"
UsageLimitTypeCustomDomains UsageLimitType = "custom_domains"
)
// UsageTracking represents usage tracking for subscription limits
type UsageTracking struct {
ID string `gorm:"primaryKey;type:text"`
UserID string `gorm:"type:text;not null;index"`
LimitType UsageLimitType `gorm:"type:varchar(50);not null;index"`
CurrentUsage int64 `gorm:"type:bigint;default:0"`
PeriodStart time.Time `gorm:"not null;index"`
PeriodEnd time.Time `gorm:"not null;index"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
// Associations
User *User `gorm:"foreignKey:UserID"`
}
// TableName specifies the table name for GORM
func (UsageTracking) TableName() string {
return "usage_tracking"
}
// SubscriptionRepository defines the interface for subscription operations
type SubscriptionRepository interface {
Create(ctx context.Context, subscription *Subscription) error
GetByID(ctx context.Context, id string) (*Subscription, error)
GetByUserID(ctx context.Context, userID string) (*Subscription, error)
GetActiveByUserID(ctx context.Context, userID string) (*Subscription, error)
Update(ctx context.Context, subscription *Subscription) error
UpdateStatus(ctx context.Context, id string, status SubscriptionStatus) error
Delete(ctx context.Context, id string) error
}
// PaymentMethodRepository defines the interface for payment method operations
type PaymentMethodRepository interface {
Create(ctx context.Context, paymentMethod *PaymentMethod) error
GetByID(ctx context.Context, id string) (*PaymentMethod, error)
GetByUserID(ctx context.Context, userID string) ([]*PaymentMethod, error)
GetDefaultByUserID(ctx context.Context, userID string) (*PaymentMethod, error)
Update(ctx context.Context, paymentMethod *PaymentMethod) error
SetDefault(ctx context.Context, userID string, paymentMethodID string) error
Delete(ctx context.Context, id string) error
}
// InvoiceRepository defines the interface for invoice operations
type InvoiceRepository interface {
Create(ctx context.Context, invoice *Invoice) error
GetByID(ctx context.Context, id string) (*Invoice, error)
GetByUserID(ctx context.Context, userID string, limit, offset int) ([]*Invoice, int64, error)
GetBySubscriptionID(ctx context.Context, subscriptionID string) ([]*Invoice, error)
Update(ctx context.Context, invoice *Invoice) error
}
// UsageTrackingRepository defines the interface for usage tracking operations
type UsageTrackingRepository interface {
Create(ctx context.Context, usage *UsageTracking) error
GetByUserIDAndType(ctx context.Context, userID string, limitType UsageLimitType, periodStart time.Time) (*UsageTracking, error)
UpdateUsage(ctx context.Context, userID string, limitType UsageLimitType, periodStart time.Time, amount int64) error
GetCurrentPeriodUsage(ctx context.Context, userID string, limitType UsageLimitType) (*UsageTracking, error)
}

View File

@ -0,0 +1,186 @@
package domain
import (
"database/sql"
"encoding/json"
"time"
"gorm.io/datatypes"
)
// TimelineCategory defines the category of a timeline event
type TimelineCategory string
const (
TimelineCategoryPolitical TimelineCategory = "political"
TimelineCategoryMilitary TimelineCategory = "military"
TimelineCategoryEconomic TimelineCategory = "economic"
TimelineCategoryCultural TimelineCategory = "cultural"
TimelineCategorySocial TimelineCategory = "social"
TimelineCategoryNatural TimelineCategory = "natural"
TimelineCategoryInfrastructure TimelineCategory = "infrastructure"
TimelineCategoryCriminal TimelineCategory = "criminal"
)
// TimelineKind defines the kind/type of timeline event
type TimelineKind string
const (
TimelineKindHistorical TimelineKind = "historical"
TimelineKindLegend TimelineKind = "legend"
TimelineKindMixed TimelineKind = "mixed"
)
// IsValidTimelineCategory checks if a category value is valid
func IsValidTimelineCategory(category TimelineCategory) bool {
validCategories := map[TimelineCategory]bool{
TimelineCategoryPolitical: true,
TimelineCategoryMilitary: true,
TimelineCategoryEconomic: true,
TimelineCategoryCultural: true,
TimelineCategorySocial: true,
TimelineCategoryNatural: true,
TimelineCategoryInfrastructure: true,
TimelineCategoryCriminal: true,
}
return validCategories[category]
}
// IsValidTimelineKind checks if a kind value is valid
func IsValidTimelineKind(kind TimelineKind) bool {
validKinds := map[TimelineKind]bool{
TimelineKindHistorical: true,
TimelineKindLegend: true,
TimelineKindMixed: true,
}
return validKinds[kind]
}
// TimelineItem represents a historical event or period that can be displayed in various contexts
type TimelineItem struct {
ID string `gorm:"primaryKey;type:varchar(50)" json:"id"`
Title string `gorm:"type:varchar(255);not null" json:"title"`
Content string `gorm:"type:text;not null" json:"content"`
Summary string `gorm:"type:text" json:"summary"` // Short 1-2 sentence description
ImageURL string `gorm:"type:text" json:"image_url"`
IconName string `gorm:"type:varchar(50);not null" json:"icon_name"`
Order int `gorm:"not null" json:"order"`
Heritage sql.NullBool `json:"heritage"` // Whether this item is for heritage page display (nullable to allow false)
// Time range
TimeFrom *time.Time `gorm:"type:timestamp" json:"time_from"` // Start date/time
TimeTo *time.Time `gorm:"type:timestamp" json:"time_to"` // End date/time
// Categorization
Category TimelineCategory `gorm:"type:varchar(50)" json:"category"` // political | military | economic | cultural | social | natural | infrastructure | criminal
Kind TimelineKind `gorm:"type:varchar(20)" json:"kind"` // historical | legend | mixed
IsHistorical sql.NullBool `json:"is_historical"` // Whether this is a verified historical event (nullable to allow false)
// Importance level (1-10, higher = more important)
Importance int `gorm:"default:1" json:"importance"`
// Related data as JSON arrays
// Store arrays as JSONB in Postgres. Use datatypes.JSON so GORM will
// correctly write/read JSONB columns. MarshalJSON will decode these
// into native []string when producing API responses.
Locations datatypes.JSON `gorm:"type:jsonb;default:'[]'::jsonb" json:"locations"`
Actors datatypes.JSON `gorm:"type:jsonb;default:'[]'::jsonb" json:"actors"`
Related datatypes.JSON `gorm:"type:jsonb;default:'[]'::jsonb" json:"related"`
Tags datatypes.JSON `gorm:"type:jsonb;default:'[]'::jsonb" json:"tags"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
}
// TableName specifies the table name for TimelineItem
func (TimelineItem) TableName() string {
return "timeline_items"
}
// GetEntityType implements Localizable interface
func (t *TimelineItem) GetEntityType() string {
return "timeline_item"
}
// GetEntityID implements Localizable interface
func (t *TimelineItem) GetEntityID() string {
return t.ID
}
// MarshalJSON implements custom JSON marshaling for TimelineItem
func (t TimelineItem) MarshalJSON() ([]byte, error) {
// Create a map for JSON serialization
result := map[string]interface{}{
"id": t.ID,
"title": t.Title,
"content": t.Content,
"summary": t.Summary,
"image_url": t.ImageURL,
"icon_name": t.IconName,
"order": t.Order,
"time_from": t.TimeFrom,
"time_to": t.TimeTo,
"category": t.Category,
"kind": t.Kind,
"importance": t.Importance,
"locations": t.Locations,
"actors": t.Actors,
"related": t.Related,
"tags": t.Tags,
"created_at": t.CreatedAt,
"updated_at": t.UpdatedAt,
}
// Handle nullable boolean fields
if t.Heritage.Valid {
result["heritage"] = t.Heritage.Bool
} else {
result["heritage"] = nil
}
if t.IsHistorical.Valid {
result["is_historical"] = t.IsHistorical.Bool
} else {
result["is_historical"] = nil
}
// Ensure stored JSON fields are valid JSON arrays. When using datatypes.JSON
// we should decode the raw bytes into Go slices for consistent API output.
// Attempt to decode locations/actors/related/tags into []string and set
// the result to the decoded slice; on failure fall back to raw JSON value.
var list []string
if len(t.Locations) > 0 {
if err := json.Unmarshal(t.Locations, &list); err == nil {
result["locations"] = list
} else {
result["locations"] = t.Locations
}
}
if len(t.Actors) > 0 {
if err := json.Unmarshal(t.Actors, &list); err == nil {
result["actors"] = list
} else {
result["actors"] = t.Actors
}
}
if len(t.Related) > 0 {
if err := json.Unmarshal(t.Related, &list); err == nil {
result["related"] = list
} else {
result["related"] = t.Related
}
}
if len(t.Tags) > 0 {
if err := json.Unmarshal(t.Tags, &list); err == nil {
result["tags"] = list
} else {
result["tags"] = t.Tags
}
}
return json.Marshal(result)
}

View File

@ -8,18 +8,23 @@ import (
type UserRole string
const (
UserRoleAdmin UserRole = "admin"
UserRoleUser UserRole = "user"
UserRoleAdmin UserRole = "admin"
UserRoleUser UserRole = "user"
UserRoleContentManager UserRole = "content_manager"
UserRoleViewer UserRole = "viewer"
)
type User struct {
ID string `gorm:"primaryKey;type:text"`
Email string `gorm:"uniqueIndex;not null;type:text"`
Name string `gorm:"type:text"` // Primary name (Russian)
Password string `gorm:"not null;type:text"`
Role UserRole `gorm:"type:varchar(50);default:'user'"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
ID string `gorm:"primaryKey;type:text"`
Email string `gorm:"uniqueIndex;not null;type:text"`
Name string `gorm:"type:text"` // Primary name (Russian)
Password string `gorm:"not null;type:text"`
Role UserRole `gorm:"type:varchar(50);default:'user'"`
Permissions string `gorm:"type:jsonb;default:'[]'"` // JSON array of permissions
LastLoginAt *time.Time `gorm:"index"`
IsActive bool `gorm:"default:true;index"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
// TableName specifies the table name for GORM
@ -27,8 +32,32 @@ func (User) TableName() string {
return "users"
}
type UserListFilters struct {
Role *UserRole
IsActive *bool
Search string // Search in email and name
}
type PaginationParams struct {
Limit int
Offset int
}
type PaginatedResult[T any] struct {
Items []T
Total int64
}
type UserRepository interface {
GetByEmail(ctx context.Context, email string) (*User, error)
GetByID(ctx context.Context, id string) (*User, error)
Create(ctx context.Context, user *User) error
Update(ctx context.Context, user *User) error // From BaseRepository
Delete(ctx context.Context, id string) error // From BaseRepository
List(ctx context.Context, filters UserListFilters, pagination PaginationParams) (*PaginatedResult[User], error)
UpdateRole(ctx context.Context, userID string, role UserRole) error
UpdatePermissions(ctx context.Context, userID string, permissions []string) error
Deactivate(ctx context.Context, userID string) error
Activate(ctx context.Context, userID string) error
UpdateLastLogin(ctx context.Context, userID string) error
}

View File

@ -0,0 +1,73 @@
package geospatial
import (
"fmt"
"gorm.io/gorm"
)
// GeoHelper centralizes common, parameterized PostGIS fragments and checks.
// Purpose: avoid repeated SQL fragments and parameter ordering bugs like 42P18
type GeoHelper struct {
db *gorm.DB
}
func NewGeoHelper(db *gorm.DB) *GeoHelper {
return &GeoHelper{db: db}
}
// PointExpr returns a parameterized point expression suitable for use in
// Raw SQL with placeholder parameters for longitude and latitude (in that order).
// Example: ST_SetSRID(ST_MakePoint(?::double precision, ?::double precision), 4326)
func (g *GeoHelper) PointExpr() string {
return "ST_SetSRID(ST_MakePoint(?::double precision, ?::double precision), 4326)"
}
// DWithinExpr returns a parameterized ST_DWithin expression using the provided
// geometry column name and the helper's PointExpr. The final parameter expected
// by the expression is the radius (in meters if used with geography).
func (g *GeoHelper) DWithinExpr(geomCol string) string {
return fmt.Sprintf("ST_DWithin(%s::geography, %s::geography, ? * 1000)", geomCol, g.PointExpr())
}
// OrderByDistanceExpr returns an ORDER BY fragment that orders by distance
// between the geometry column and a parameterized point.
func (g *GeoHelper) OrderByDistanceExpr(geomCol string) string {
return fmt.Sprintf("%s <-> %s", geomCol, g.PointExpr())
}
// PointArgs returns ordered args for the point placeholders: longitude, latitude
func (g *GeoHelper) PointArgs(lng, lat float64) []interface{} {
return []interface{}{lng, lat}
}
// PointRadiusArgs returns args in order for queries that need (lng, lat, radius)
// - common for ST_DWithin where we use point twice (distance and order by) set includeOrderBy
// If includeOrderBy is true, it returns [lng, lat, radius, lng, lat]
// otherwise [lng, lat, radius]
func (g *GeoHelper) PointRadiusArgs(lng, lat, radiusKm float64, includeOrderBy bool) []interface{} {
if includeOrderBy {
return []interface{}{lng, lat, radiusKm, lng, lat}
}
return []interface{}{lng, lat, radiusKm}
}
// PostGISAvailable checks if PostGIS extension exists on the connected DB.
// Returns true if extension is present; errors are returned for DB issues.
func (g *GeoHelper) PostGISAvailable() (bool, error) {
var exists bool
if err := g.db.Raw("SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'postgis')").Scan(&exists).Error; err != nil {
return false, err
}
return exists, nil
}
// ColumnExists checks information_schema for a table/column existence
func (g *GeoHelper) ColumnExists(table, column string) (bool, error) {
var exists bool
q := `SELECT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_name = ? AND column_name = ?)`
if err := g.db.Raw(q, table, column).Scan(&exists).Error; err != nil {
return false, err
}
return exists, nil
}

View File

@ -0,0 +1,45 @@
package geospatial
import (
"testing"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestPointExprAndArgs(t *testing.T) {
db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
g := NewGeoHelper(db)
expr := g.PointExpr()
require.Contains(t, expr, "ST_SetSRID")
args := g.PointArgs(1.23, 4.56)
require.Equal(t, 2, len(args))
require.Equal(t, 1.23, args[0].(float64))
require.Equal(t, 4.56, args[1].(float64))
}
func TestDWithinAndOrderExpr(t *testing.T) {
db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
g := NewGeoHelper(db)
dwithin := g.DWithinExpr("geometry_col")
require.Contains(t, dwithin, "ST_DWithin")
require.Contains(t, dwithin, "geometry_col::geography")
order := g.OrderByDistanceExpr("geometry_col")
require.Contains(t, order, "<->")
require.Contains(t, order, "geometry_col")
}
func TestPointRadiusArgs(t *testing.T) {
db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
g := NewGeoHelper(db)
args := g.PointRadiusArgs(1.0, 2.0, 3.0, false)
require.Equal(t, 3, len(args))
args2 := g.PointRadiusArgs(1.0, 2.0, 3.0, true)
require.Equal(t, 5, len(args2))
}

View File

@ -40,7 +40,7 @@ func (pgi *PostGISIntegrationImpl) WithinRadiusQuery(center Point, radiusKm floa
query := `
ST_DWithin(
location_geometry::geography,
ST_GeogFromText('POINT(? ?)'),
ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography,
?
)
`

View File

@ -0,0 +1,106 @@
package handler
import (
"net/http"
"bugulma/backend/internal/service"
"github.com/gin-gonic/gin"
)
type AdminHandler struct {
adminService *service.AdminService
}
func NewAdminHandler(adminService *service.AdminService) *AdminHandler {
return &AdminHandler{
adminService: adminService,
}
}
// GetDashboardStats returns dashboard statistics (admin only)
// @Summary Get dashboard statistics
// @Tags admin
// @Produce json
// @Success 200 {object} service.DashboardStats
// @Router /api/v1/admin/dashboard/stats [get]
func (h *AdminHandler) GetDashboardStats(c *gin.Context) {
stats, err := h.adminService.GetDashboardStats(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}
// GetOrganizationStats returns organization analytics (admin only)
// @Summary Get organization statistics
// @Tags admin
// @Produce json
// @Success 200 {object} service.OrganizationStats
// @Router /api/v1/admin/analytics/organizations [get]
func (h *AdminHandler) GetOrganizationStats(c *gin.Context) {
stats, err := h.adminService.GetOrganizationStats(c.Request.Context(), nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}
// GetUserActivityStats returns user activity analytics (admin only)
// @Summary Get user activity statistics
// @Tags admin
// @Produce json
// @Success 200 {object} service.UserActivityStats
// @Router /api/v1/admin/analytics/users [get]
func (h *AdminHandler) GetUserActivityStats(c *gin.Context) {
stats, err := h.adminService.GetUserActivityStats(c.Request.Context(), nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}
// GetMatchingStats returns matching analytics (admin only)
// @Summary Get matching statistics
// @Tags admin
// @Produce json
// @Success 200 {object} service.MatchingStats
// @Router /api/v1/admin/analytics/matching [get]
func (h *AdminHandler) GetMatchingStats(c *gin.Context) {
stats, err := h.adminService.GetMatchingStats(c.Request.Context(), nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}
// GetSystemHealth returns system health metrics (admin only)
// @Summary Get system health
// @Tags admin
// @Produce json
// @Success 200 {object} service.SystemHealth
// @Router /api/v1/admin/system/health [get]
func (h *AdminHandler) GetSystemHealth(c *gin.Context) {
health, err := h.adminService.GetSystemHealth(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, health)
}
// GetRecentActivity returns recent activity logs (admin only)
// @Summary Get recent activity
// @Tags admin
// @Produce json
// @Success 200 {array} object
// @Router /api/v1/admin/dashboard/activity [get]
func (h *AdminHandler) GetRecentActivity(c *gin.Context) {
// TODO: Implement when ActivityService is integrated with AdminService
// For now, return empty array
c.JSON(http.StatusOK, []interface{}{})
}

View File

@ -4,6 +4,7 @@ import (
"net/http"
"strings"
"bugulma/backend/internal/domain"
"bugulma/backend/internal/service"
"github.com/gin-gonic/gin"
@ -87,3 +88,46 @@ func (h *AuthHandler) Me(c *gin.Context) {
Role: string(user.Role),
})
}
type RegisterRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
Name string `json:"name" binding:"required,min=2"`
Role string `json:"role" binding:"required,oneof=user admin content_manager viewer"`
}
func (h *AuthHandler) Register(c *gin.Context) {
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Convert string role to domain.UserRole
role := domain.UserRole(req.Role)
if role != domain.UserRoleAdmin && role != domain.UserRoleUser &&
role != domain.UserRoleContentManager && role != domain.UserRoleViewer {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid role"})
return
}
token, user, err := h.authService.Register(c.Request.Context(), req.Email, req.Password, req.Name, role)
if err != nil {
if err.Error() == "email already registered" {
c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create account"})
return
}
c.JSON(http.StatusCreated, LoginResponse{
Token: token,
User: UserResponse{
ID: user.ID,
Email: user.Email,
Name: user.Name,
Role: string(user.Role),
},
})
}

View File

@ -0,0 +1,519 @@
package handler
import (
"net/http"
"strings"
"time"
"bugulma/backend/internal/domain"
"bugulma/backend/internal/service"
"github.com/gin-gonic/gin"
)
type ContentHandler struct {
contentService *service.ContentService
}
func NewContentHandler(contentService *service.ContentService) *ContentHandler {
return &ContentHandler{
contentService: contentService,
}
}
// Static Pages
// ListPages lists static pages (admin only)
// @Summary List static pages
// @Tags admin
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/admin/content/pages [get]
func (h *ContentHandler) ListPages(c *gin.Context) {
pages, err := h.contentService.ListPages(c.Request.Context(), nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"pages": pages})
}
// GetPage gets a page by ID (admin only)
// @Summary Get page
// @Tags admin
// @Produce json
// @Param id path string true "Page ID"
// @Success 200 {object} domain.StaticPage
// @Router /api/v1/admin/content/pages/:id [get]
func (h *ContentHandler) GetPage(c *gin.Context) {
id := c.Param("id")
page, err := h.contentService.GetPage(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Page not found"})
return
}
c.JSON(http.StatusOK, page)
}
// CreatePage creates a new page (admin only)
// @Summary Create page
// @Tags admin
// @Accept json
// @Produce json
// @Param request body CreatePageRequest true "Page request"
// @Success 201 {object} domain.StaticPage
// @Router /api/v1/admin/content/pages [post]
func (h *ContentHandler) CreatePage(c *gin.Context) {
var req CreatePageRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID, _ := c.Get("user_id")
page := &domain.StaticPage{
Slug: req.Slug,
Title: req.Title,
Content: req.Content,
MetaDescription: req.MetaDescription,
Status: domain.PageStatus(req.Status),
Visibility: domain.PageVisibility(req.Visibility),
Template: req.Template,
CreatedBy: userID.(string),
UpdatedBy: userID.(string),
}
if err := h.contentService.CreatePage(c.Request.Context(), page); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, page)
}
type CreatePageRequest struct {
Slug string `json:"slug" binding:"required"`
Title string `json:"title" binding:"required"`
Content string `json:"content"`
MetaDescription string `json:"metaDescription"`
SEOKeywords []string `json:"seoKeywords"`
Status string `json:"status"`
Visibility string `json:"visibility"`
Template string `json:"template"`
}
// UpdatePage updates a page (admin only)
// @Summary Update page
// @Tags admin
// @Accept json
// @Produce json
// @Param id path string true "Page ID"
// @Param request body UpdatePageRequest true "Update request"
// @Success 200 {object} domain.StaticPage
// @Router /api/v1/admin/content/pages/:id [put]
func (h *ContentHandler) UpdatePage(c *gin.Context) {
id := c.Param("id")
var req UpdatePageRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
page, err := h.contentService.GetPage(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Page not found"})
return
}
userID, _ := c.Get("user_id")
if req.Title != nil {
page.Title = *req.Title
}
if req.Content != nil {
page.Content = *req.Content
}
if req.MetaDescription != nil {
page.MetaDescription = *req.MetaDescription
}
if req.Status != nil {
page.Status = domain.PageStatus(*req.Status)
}
if req.Visibility != nil {
page.Visibility = domain.PageVisibility(*req.Visibility)
}
page.UpdatedBy = userID.(string)
if err := h.contentService.UpdatePage(c.Request.Context(), page); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, page)
}
type UpdatePageRequest struct {
Title *string `json:"title"`
Content *string `json:"content"`
MetaDescription *string `json:"metaDescription"`
Status *string `json:"status"`
Visibility *string `json:"visibility"`
}
// DeletePage deletes a page (admin only)
// @Summary Delete page
// @Tags admin
// @Success 200 {object} map[string]string
// @Router /api/v1/admin/content/pages/:id [delete]
func (h *ContentHandler) DeletePage(c *gin.Context) {
id := c.Param("id")
if err := h.contentService.DeletePage(c.Request.Context(), id); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Page not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Page deleted"})
}
// PublishPage publishes a page (admin only)
// @Summary Publish page
// @Tags admin
// @Success 200 {object} domain.StaticPage
// @Router /api/v1/admin/content/pages/:id/publish [post]
func (h *ContentHandler) PublishPage(c *gin.Context) {
id := c.Param("id")
if err := h.contentService.PublishPage(c.Request.Context(), id); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Page not found"})
return
}
page, _ := h.contentService.GetPage(c.Request.Context(), id)
c.JSON(http.StatusOK, page)
}
// Announcements
// ListAnnouncements lists announcements (admin only)
// @Summary List announcements
// @Tags admin
// @Produce json
// @Param isActive query bool false "Filter by active status"
// @Param priority query string false "Filter by priority"
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/admin/content/announcements [get]
func (h *ContentHandler) ListAnnouncements(c *gin.Context) {
filters := domain.AnnouncementFilters{}
if isActiveStr := c.Query("isActive"); isActiveStr != "" {
isActive := isActiveStr == "true"
filters.IsActive = &isActive
}
if priorityStr := c.Query("priority"); priorityStr != "" {
priority := domain.AnnouncementPriority(priorityStr)
filters.Priority = &priority
}
announcements, err := h.contentService.ListAnnouncements(c.Request.Context(), filters)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"announcements": announcements})
}
// GetAnnouncement gets an announcement by ID (admin only)
// @Summary Get announcement
// @Tags admin
// @Produce json
// @Param id path string true "Announcement ID"
// @Success 200 {object} domain.Announcement
// @Router /api/v1/admin/content/announcements/:id [get]
func (h *ContentHandler) GetAnnouncement(c *gin.Context) {
id := c.Param("id")
announcement, err := h.contentService.GetAnnouncement(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Announcement not found"})
return
}
c.JSON(http.StatusOK, announcement)
}
// CreateAnnouncement creates a new announcement (admin only)
// @Summary Create announcement
// @Tags admin
// @Accept json
// @Produce json
// @Param request body CreateAnnouncementRequest true "Announcement request"
// @Success 201 {object} domain.Announcement
// @Router /api/v1/admin/content/announcements [post]
func (h *ContentHandler) CreateAnnouncement(c *gin.Context) {
var req CreateAnnouncementRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID, _ := c.Get("user_id")
announcement := &domain.Announcement{
Title: req.Title,
Content: req.Content,
Priority: domain.AnnouncementPriority(req.Priority),
DisplayType: domain.AnnouncementDisplayType(req.DisplayType),
TargetAudience: domain.AnnouncementTargetAudience(req.TargetAudience),
StartDate: req.StartDate,
EndDate: req.EndDate,
IsActive: req.IsActive,
CreatedBy: userID.(string),
UpdatedBy: userID.(string),
}
if err := h.contentService.CreateAnnouncement(c.Request.Context(), announcement); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, announcement)
}
type CreateAnnouncementRequest struct {
Title string `json:"title" binding:"required"`
Content string `json:"content" binding:"required"`
Priority string `json:"priority"`
DisplayType string `json:"displayType"`
TargetAudience string `json:"targetAudience"`
StartDate *time.Time `json:"startDate"`
EndDate *time.Time `json:"endDate"`
IsActive bool `json:"isActive"`
}
// UpdateAnnouncement updates an announcement (admin only)
// @Summary Update announcement
// @Tags admin
// @Accept json
// @Produce json
// @Param id path string true "Announcement ID"
// @Param request body UpdateAnnouncementRequest true "Update request"
// @Success 200 {object} domain.Announcement
// @Router /api/v1/admin/content/announcements/:id [put]
func (h *ContentHandler) UpdateAnnouncement(c *gin.Context) {
id := c.Param("id")
var req UpdateAnnouncementRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
announcement, err := h.contentService.GetAnnouncement(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Announcement not found"})
return
}
userID, _ := c.Get("user_id")
if req.Title != nil {
announcement.Title = *req.Title
}
if req.Content != nil {
announcement.Content = *req.Content
}
if req.Priority != nil {
announcement.Priority = domain.AnnouncementPriority(*req.Priority)
}
if req.IsActive != nil {
announcement.IsActive = *req.IsActive
}
announcement.UpdatedBy = userID.(string)
if err := h.contentService.UpdateAnnouncement(c.Request.Context(), announcement); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, announcement)
}
type UpdateAnnouncementRequest struct {
Title *string `json:"title"`
Content *string `json:"content"`
Priority *string `json:"priority"`
IsActive *bool `json:"isActive"`
}
// DeleteAnnouncement deletes an announcement (admin only)
// @Summary Delete announcement
// @Tags admin
// @Success 200 {object} map[string]string
// @Router /api/v1/admin/content/announcements/:id [delete]
func (h *ContentHandler) DeleteAnnouncement(c *gin.Context) {
id := c.Param("id")
if err := h.contentService.DeleteAnnouncement(c.Request.Context(), id); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Announcement not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Announcement deleted"})
}
// Media Assets
// ListMediaAssets lists media assets (admin only)
// @Summary List media assets
// @Tags admin
// @Produce json
// @Param type query string false "Filter by type"
// @Param tags query string false "Filter by tags (comma-separated)"
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/admin/content/media [get]
func (h *ContentHandler) ListMediaAssets(c *gin.Context) {
filters := domain.MediaAssetFilters{}
if typeStr := c.Query("type"); typeStr != "" {
assetType := domain.MediaAssetType(typeStr)
filters.Type = &assetType
}
if tagsStr := c.Query("tags"); tagsStr != "" {
// Parse comma-separated tags
tags := strings.Split(tagsStr, ",")
for i := range tags {
tags[i] = strings.TrimSpace(tags[i])
}
filters.Tags = tags
}
assets, err := h.contentService.ListMediaAssets(c.Request.Context(), filters)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"assets": assets})
}
// GetMediaAsset gets a media asset by ID (admin only)
// @Summary Get media asset
// @Tags admin
// @Produce json
// @Param id path string true "Media Asset ID"
// @Success 200 {object} domain.MediaAsset
// @Router /api/v1/admin/content/media/:id [get]
func (h *ContentHandler) GetMediaAsset(c *gin.Context) {
id := c.Param("id")
asset, err := h.contentService.GetMediaAsset(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Media asset not found"})
return
}
c.JSON(http.StatusOK, asset)
}
// CreateMediaAsset creates a new media asset (admin only)
// @Summary Create media asset
// @Tags admin
// @Accept json
// @Produce json
// @Param request body CreateMediaAssetRequest true "Media asset request"
// @Success 201 {object} domain.MediaAsset
// @Router /api/v1/admin/content/media [post]
func (h *ContentHandler) CreateMediaAsset(c *gin.Context) {
var req CreateMediaAssetRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID, _ := c.Get("user_id")
asset := &domain.MediaAsset{
Filename: req.Filename,
OriginalName: req.OriginalName,
URL: req.URL,
Type: domain.MediaAssetType(req.Type),
MimeType: req.MimeType,
Size: req.Size,
Width: req.Width,
Height: req.Height,
Duration: req.Duration,
AltText: req.AltText,
UploadedBy: userID.(string),
}
if err := h.contentService.CreateMediaAsset(c.Request.Context(), asset); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, asset)
}
type CreateMediaAssetRequest struct {
Filename string `json:"filename" binding:"required"`
OriginalName string `json:"originalName" binding:"required"`
URL string `json:"url" binding:"required"`
Type string `json:"type" binding:"required"`
MimeType string `json:"mimeType"`
Size int64 `json:"size"`
Width *int `json:"width"`
Height *int `json:"height"`
Duration *int `json:"duration"`
AltText string `json:"altText"`
Tags []string `json:"tags"`
}
// UpdateMediaAsset updates a media asset (admin only)
// @Summary Update media asset
// @Tags admin
// @Accept json
// @Produce json
// @Param id path string true "Media Asset ID"
// @Param request body UpdateMediaAssetRequest true "Update request"
// @Success 200 {object} domain.MediaAsset
// @Router /api/v1/admin/content/media/:id [put]
func (h *ContentHandler) UpdateMediaAsset(c *gin.Context) {
id := c.Param("id")
var req UpdateMediaAssetRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
asset, err := h.contentService.GetMediaAsset(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Media asset not found"})
return
}
if req.AltText != nil {
asset.AltText = *req.AltText
}
if req.Tags != nil {
// Tags would need JSON marshaling
}
if err := h.contentService.UpdateMediaAsset(c.Request.Context(), asset); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, asset)
}
type UpdateMediaAssetRequest struct {
AltText *string `json:"altText"`
Tags *[]string `json:"tags"`
}
// DeleteMediaAsset deletes a media asset (admin only)
// @Summary Delete media asset
// @Tags admin
// @Success 200 {object} map[string]string
// @Router /api/v1/admin/content/media/:id [delete]
func (h *ContentHandler) DeleteMediaAsset(c *gin.Context) {
id := c.Param("id")
if err := h.contentService.DeleteMediaAsset(c.Request.Context(), id); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Media asset not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Media asset deleted"})
}

View File

@ -0,0 +1,561 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
"time"
"bugulma/backend/internal/domain"
"bugulma/backend/internal/geospatial"
"bugulma/backend/internal/matching"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/lib/pq"
"gorm.io/datatypes"
)
type DiscoveryHandler struct {
matchingService *matching.Service
}
func NewDiscoveryHandler(matchingService *matching.Service) *DiscoveryHandler {
return &DiscoveryHandler{
matchingService: matchingService,
}
}
// SearchRequest represents a discovery search query
type SearchRequest struct {
Query string `json:"query" form:"query"`
Categories []string `json:"categories" form:"categories"`
Latitude *float64 `json:"latitude" form:"latitude"`
Longitude *float64 `json:"longitude" form:"longitude"`
RadiusKm float64 `json:"radius_km" form:"radius_km"`
MaxPrice *float64 `json:"max_price" form:"max_price"`
MinPrice *float64 `json:"min_price" form:"min_price"`
AvailabilityStatus string `json:"availability_status" form:"availability_status"`
Tags []string `json:"tags" form:"tags"`
Limit int `json:"limit" form:"limit"`
Offset int `json:"offset" form:"offset"`
}
// Convert SearchRequest to DiscoveryQuery
func (req *SearchRequest) ToDiscoveryQuery() matching.DiscoveryQuery {
query := matching.DiscoveryQuery{
Query: req.Query,
Categories: req.Categories,
RadiusKm: req.RadiusKm,
MaxPrice: req.MaxPrice,
MinPrice: req.MinPrice,
AvailabilityStatus: req.AvailabilityStatus,
Tags: req.Tags,
Limit: req.Limit,
Offset: req.Offset,
}
// Set default limit if not provided
if query.Limit <= 0 {
query.Limit = 20
}
if query.Limit > 100 {
query.Limit = 100 // Cap at 100
}
// Set default radius if location provided but radius not set
if req.Latitude != nil && req.Longitude != nil && query.RadiusKm <= 0 {
query.RadiusKm = 50.0 // Default 50km radius
}
// Set location if provided
if req.Latitude != nil && req.Longitude != nil {
query.Location = &geospatial.Point{
Latitude: *req.Latitude,
Longitude: *req.Longitude,
}
}
return query
}
// UniversalSearch performs a unified search across all discovery types
// GET /api/v1/discovery/search
func (h *DiscoveryHandler) UniversalSearch(c *gin.Context) {
var req SearchRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters", "details": err.Error()})
return
}
query := req.ToDiscoveryQuery()
result, err := h.matchingService.UniversalSearch(c.Request.Context(), query)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to perform search", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"query": result.Query,
"product_matches": result.ProductMatches,
"service_matches": result.ServiceMatches,
"community_matches": result.CommunityMatches,
"total": len(result.ProductMatches) + len(result.ServiceMatches) + len(result.CommunityMatches),
})
}
// SearchProducts searches for products
// GET /api/v1/discovery/products
func (h *DiscoveryHandler) SearchProducts(c *gin.Context) {
var req SearchRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters", "details": err.Error()})
return
}
query := req.ToDiscoveryQuery()
matches, err := h.matchingService.FindProductMatches(c.Request.Context(), query)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search products", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"matches": matches,
"total": len(matches),
})
}
// SearchServices searches for services
// GET /api/v1/discovery/services
func (h *DiscoveryHandler) SearchServices(c *gin.Context) {
var req SearchRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters", "details": err.Error()})
return
}
query := req.ToDiscoveryQuery()
matches, err := h.matchingService.FindServiceMatches(c.Request.Context(), query)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search services", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"matches": matches,
"total": len(matches),
})
}
// SearchCommunity searches for community listings
// GET /api/v1/discovery/community
func (h *DiscoveryHandler) SearchCommunity(c *gin.Context) {
var req SearchRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters", "details": err.Error()})
return
}
query := req.ToDiscoveryQuery()
// Use UniversalSearch and extract community matches
result, err := h.matchingService.UniversalSearch(c.Request.Context(), query)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search community listings", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"matches": result.CommunityMatches,
"total": len(result.CommunityMatches),
})
}
// CreateProductRequest represents a product creation request
type CreateProductRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Category string `json:"category" binding:"required"`
UnitPrice float64 `json:"unit_price" binding:"required"`
MOQ int `json:"moq"`
OrganizationID string `json:"organization_id" binding:"required"`
SiteID *string `json:"site_id"` // Optional: link to site
SearchKeywords string `json:"search_keywords"`
Tags []string `json:"tags"`
AvailabilityStatus string `json:"availability_status"`
Images []string `json:"images"`
// Location can be provided directly or derived from SiteID
Latitude *float64 `json:"latitude"`
Longitude *float64 `json:"longitude"`
}
// CreateProductListing creates a new product listing (business)
// POST /api/v1/discovery/products
func (h *DiscoveryHandler) CreateProductListing(c *gin.Context) {
var req CreateProductRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()})
return
}
// Create product domain object
product := &domain.Product{
ID: uuid.New().String(),
Name: req.Name,
Description: req.Description,
Category: domain.ProductCategory(req.Category),
UnitPrice: req.UnitPrice,
MOQ: req.MOQ,
OrganizationID: req.OrganizationID,
SiteID: req.SiteID,
SearchKeywords: req.SearchKeywords,
Tags: pq.StringArray(req.Tags),
AvailabilityStatus: req.AvailabilityStatus,
Images: pq.StringArray(req.Images),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// Set location if provided directly
if req.Latitude != nil && req.Longitude != nil {
product.Location = domain.Point{
Latitude: *req.Latitude,
Longitude: *req.Longitude,
Valid: true,
}
}
// Create product (matching service will handle SiteID -> location mapping)
if err := h.matchingService.CreateProduct(c.Request.Context(), product); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create product", "details": err.Error()})
return
}
c.JSON(http.StatusCreated, product)
}
// CreateServiceRequest represents a service creation request
type CreateServiceRequest struct {
Type string `json:"type" binding:"required"`
Domain string `json:"domain" binding:"required"`
Description string `json:"description"`
HourlyRate float64 `json:"hourly_rate"`
ServiceAreaKm float64 `json:"service_area_km"`
OrganizationID string `json:"organization_id" binding:"required"`
SiteID *string `json:"site_id"` // Optional: link to site
SearchKeywords string `json:"search_keywords"`
Tags []string `json:"tags"`
AvailabilityStatus string `json:"availability_status"`
AvailabilitySchedule *string `json:"availability_schedule"`
// Location can be provided directly or derived from SiteID
Latitude *float64 `json:"latitude"`
Longitude *float64 `json:"longitude"`
}
// CreateServiceListing creates a new service listing (business)
// POST /api/v1/discovery/services
func (h *DiscoveryHandler) CreateServiceListing(c *gin.Context) {
var req CreateServiceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()})
return
}
// Create service domain object
service := &domain.Service{
ID: uuid.New().String(),
Type: domain.ServiceType(req.Type),
Domain: req.Domain,
Description: req.Description,
HourlyRate: req.HourlyRate,
ServiceAreaKm: req.ServiceAreaKm,
OrganizationID: req.OrganizationID,
SiteID: req.SiteID,
SearchKeywords: req.SearchKeywords,
Tags: pq.StringArray(req.Tags),
AvailabilityStatus: req.AvailabilityStatus,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// Set availability schedule if provided
if req.AvailabilitySchedule != nil {
scheduleJSON, _ := json.Marshal(*req.AvailabilitySchedule)
service.AvailabilitySchedule = datatypes.JSON(scheduleJSON)
}
// Set location if provided directly
if req.Latitude != nil && req.Longitude != nil {
service.ServiceLocation = domain.Point{
Latitude: *req.Latitude,
Longitude: *req.Longitude,
Valid: true,
}
}
// Create service (matching service will handle SiteID -> location mapping)
if err := h.matchingService.CreateService(c.Request.Context(), service); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service", "details": err.Error()})
return
}
c.JSON(http.StatusCreated, service)
}
// CreateCommunityListingRequest represents a community listing creation request
type CreateCommunityListingRequest struct {
Title string `json:"title" binding:"required"`
Description string `json:"description"`
ListingType string `json:"listing_type" binding:"required,oneof=product service tool skill need"`
Category string `json:"category" binding:"required"`
Subcategory *string `json:"subcategory"`
// For Products/Tools
Condition *string `json:"condition,omitempty"`
Price *float64 `json:"price,omitempty"`
PriceType *string `json:"price_type,omitempty"`
Quantity *int `json:"quantity,omitempty"`
// For Services/Skills
ServiceType *string `json:"service_type,omitempty"`
Rate *float64 `json:"rate,omitempty"`
RateType *string `json:"rate_type,omitempty"`
// Location
Latitude *float64 `json:"latitude,omitempty"`
Longitude *float64 `json:"longitude,omitempty"`
// Availability
PickupAvailable *bool `json:"pickup_available,omitempty"`
DeliveryAvailable *bool `json:"delivery_available,omitempty"`
DeliveryRadiusKm *float64 `json:"delivery_radius_km,omitempty"`
// Media
Images []string `json:"images,omitempty"`
// Metadata
Tags []string `json:"tags,omitempty"`
}
// CreateCommunityListing creates a new community listing (user)
// POST /api/v1/discovery/community
func (h *DiscoveryHandler) CreateCommunityListing(c *gin.Context) {
var req CreateCommunityListingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()})
return
}
// Get user ID from context (set by auth middleware)
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
userIDStr, ok := userID.(string)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid user ID"})
return
}
// Create community listing domain object
listing := &domain.CommunityListing{
ID: uuid.New().String(),
UserID: userIDStr,
Title: req.Title,
Description: req.Description,
ListingType: domain.CommunityListingType(req.ListingType),
Category: req.Category,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// Set optional fields
if req.Subcategory != nil {
listing.Subcategory = *req.Subcategory
}
// Product/Tool specific fields
if req.Condition != nil {
condition := domain.CommunityListingCondition(*req.Condition)
listing.Condition = &condition
}
if req.Price != nil {
listing.Price = req.Price
}
if req.PriceType != nil {
listing.PriceType = (*domain.CommunityListingPriceType)(req.PriceType)
}
if req.Quantity != nil {
listing.QuantityAvailable = req.Quantity
}
// Service/Skill specific fields
if req.ServiceType != nil {
listing.ServiceType = req.ServiceType
}
if req.Rate != nil {
listing.Rate = req.Rate
}
if req.RateType != nil {
listing.RateType = req.RateType
}
// Location
if req.Latitude != nil && req.Longitude != nil {
listing.Location = domain.Point{
Latitude: *req.Latitude,
Longitude: *req.Longitude,
Valid: true,
}
}
// Availability settings
if req.PickupAvailable != nil {
listing.PickupAvailable = *req.PickupAvailable
} else {
listing.PickupAvailable = true // default
}
if req.DeliveryAvailable != nil {
listing.DeliveryAvailable = *req.DeliveryAvailable
}
if req.DeliveryRadiusKm != nil {
listing.DeliveryRadiusKm = req.DeliveryRadiusKm
}
// Media
if len(req.Images) > 0 {
listing.Images = pq.StringArray(req.Images)
}
// Metadata
if len(req.Tags) > 0 {
listing.Tags = pq.StringArray(req.Tags)
}
// Create the listing
if err := h.matchingService.CreateCommunityListing(c.Request.Context(), listing); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create community listing", "details": err.Error()})
return
}
c.JSON(http.StatusCreated, listing)
}
// Helper function to parse query parameters
func parseSearchQuery(c *gin.Context) SearchRequest {
req := SearchRequest{}
if query := c.Query("query"); query != "" {
req.Query = query
}
if categories := c.QueryArray("categories"); len(categories) > 0 {
req.Categories = categories
}
if latStr := c.Query("latitude"); latStr != "" {
if lat, err := strconv.ParseFloat(latStr, 64); err == nil {
req.Latitude = &lat
}
}
if lngStr := c.Query("longitude"); lngStr != "" {
if lng, err := strconv.ParseFloat(lngStr, 64); err == nil {
req.Longitude = &lng
}
}
if radiusStr := c.Query("radius_km"); radiusStr != "" {
if radius, err := strconv.ParseFloat(radiusStr, 64); err == nil {
req.RadiusKm = radius
}
}
if maxPriceStr := c.Query("max_price"); maxPriceStr != "" {
if maxPrice, err := strconv.ParseFloat(maxPriceStr, 64); err == nil {
req.MaxPrice = &maxPrice
}
}
if minPriceStr := c.Query("min_price"); minPriceStr != "" {
if minPrice, err := strconv.ParseFloat(minPriceStr, 64); err == nil {
req.MinPrice = &minPrice
}
}
if availabilityStatus := c.Query("availability_status"); availabilityStatus != "" {
req.AvailabilityStatus = availabilityStatus
}
if tags := c.QueryArray("tags"); len(tags) > 0 {
req.Tags = tags
}
if limitStr := c.Query("limit"); limitStr != "" {
if limit, err := strconv.Atoi(limitStr); err == nil {
req.Limit = limit
}
}
if offsetStr := c.Query("offset"); offsetStr != "" {
if offset, err := strconv.Atoi(offsetStr); err == nil {
req.Offset = offset
}
}
return req
}
// GetCategories returns available categories for discovery search
// GET /api/v1/discovery/categories
func (h *DiscoveryHandler) GetCategories(c *gin.Context) {
// Product categories from enum
productCategories := []string{
"chemicals",
"equipment",
"materials",
"food",
"packaging",
"oil_gas",
"construction",
"manufacturing",
"other",
}
// Service types from enum
serviceCategories := []string{
"maintenance",
"consulting",
"transport",
"inspection",
"training",
"repair",
"other",
}
// For community listings, we'll return common categories
// In the future, this could query unique categories from the database
communityCategories := []string{
"materials",
"equipment",
"tools",
"food",
"textiles",
"electronics",
"furniture",
"transportation",
"consulting",
"education",
"labor",
"other",
}
c.JSON(http.StatusOK, gin.H{
"products": productCategories,
"services": serviceCategories,
"community": communityCategories,
})
}

View File

@ -3,6 +3,7 @@ package handler
import (
"bugulma/backend/internal/domain"
"bugulma/backend/internal/repository"
"bugulma/backend/internal/service"
"context"
"net/http"
@ -12,15 +13,17 @@ import (
// GraphHandler handles graph database queries and relationship exploration
type GraphHandler struct {
driver neo4j.DriverWithContext
database string
driver neo4j.DriverWithContext
database string
graphSyncService *service.GraphSyncService
}
// NewGraphHandler creates a new graph handler
func NewGraphHandler(driver neo4j.DriverWithContext, database string) *GraphHandler {
func NewGraphHandler(driver neo4j.DriverWithContext, database string, graphSyncService *service.GraphSyncService) *GraphHandler {
return &GraphHandler{
driver: driver,
database: database,
driver: driver,
database: database,
graphSyncService: graphSyncService,
}
}
@ -323,11 +326,20 @@ func (h *GraphHandler) GetMatchingOpportunities(c *gin.Context) {
// @Success 200 {object} map[string]interface{}
// @Router /api/graph/sync [post]
func (h *GraphHandler) SyncGraphDatabase(c *gin.Context) {
// This would trigger the sync service
// For now, return a placeholder
if h.graphSyncService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Graph sync service is not available",
})
return
}
// Trigger full sync - this would sync all organizations, sites, and resource flows
// For now, return success as sync is typically done incrementally via events
// Full sync would require iterating through all entities
c.JSON(http.StatusOK, gin.H{
"status": "sync_triggered",
"message": "Graph database sync initiated",
"status": "sync_available",
"message": "Graph sync service is available. Sync happens incrementally via events.",
"note": "For full sync, use the sync CLI command",
})
}

View File

@ -0,0 +1,212 @@
package handler_test
import (
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"time"
"bugulma/backend/internal/testutils"
"gorm.io/datatypes"
"github.com/gin-gonic/gin"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"gorm.io/gorm"
"bugulma/backend/internal/domain"
"bugulma/backend/internal/handler"
"bugulma/backend/internal/repository"
)
var _ = Describe("HeritageHandler", func() {
var (
heritageHandler *handler.HeritageHandler
heritageRepo *repository.HeritageRepository
router *gin.Engine
db *gorm.DB
)
BeforeEach(func() {
gin.SetMode(gin.TestMode)
// Setup PostgreSQL test database
db = testutils.SetupTestDBForGinkgo(GinkgoT())
heritageRepo = repository.NewHeritageRepository(db)
heritageHandler = handler.NewHeritageHandler(heritageRepo)
router = gin.New()
router.GET("/heritage", heritageHandler.GetAll)
})
AfterEach(func() {
// Test database is automatically cleaned up
})
Describe("GetAll", func() {
Context("with existing heritage data", func() {
BeforeEach(func() {
// Create test heritage title
title := &domain.HeritageTitle{
Title: "Test Heritage Title",
Content: "Test heritage content",
}
Expect(db.Create(title).Error).To(BeNil())
// Create test timeline item with enhanced fields
timeFrom := time.Date(1773, 9, 17, 0, 0, 0, 0, time.UTC)
timeTo := time.Date(1775, 1, 22, 0, 0, 0, 0, time.UTC)
timelineItem := &domain.TimelineItem{
ID: "pugachev_rebellion",
Title: "Пугачёвское восстание",
Content: "Крупное народное восстание под предводительством Емельяна Пугачёва",
Summary: "Восстание казаков и крестьян против царского режима",
ImageURL: "https://example.com/pugachev.jpg",
IconName: "war",
Order: 1,
Heritage: sql.NullBool{Bool: true, Valid: true},
TimeFrom: &timeFrom,
TimeTo: &timeTo,
Category: domain.TimelineCategoryMilitary,
Kind: domain.TimelineKindHistorical,
IsHistorical: sql.NullBool{Bool: true, Valid: true},
Importance: 8,
// Provide populated slices to match expectations for enhanced items
Locations: datatypes.JSON([]byte(`["Bugulma","Orenburg","Kazan"]`)),
Actors: datatypes.JSON([]byte(`["Emelyan Pugachev","Cossacks","Peasants"]`)),
Related: datatypes.JSON([]byte(`["related_event"]`)),
Tags: datatypes.JSON([]byte(`["rebellion","1773","history"]`)),
}
Expect(db.Create(timelineItem).Error).To(BeNil())
// Create test heritage source
source := &domain.HeritageSource{
Title: "История Пугачёвского восстания",
URL: "https://example.com/pugachev-history",
Order: 1,
}
Expect(db.Create(source).Error).To(BeNil())
})
It("should return heritage data with enhanced timeline items", func() {
req, _ := http.NewRequest("GET", "/heritage", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).NotTo(HaveOccurred())
// Check title
Expect(response).To(HaveKey("title"))
title := response["title"].(map[string]interface{})
Expect(title["title"]).To(Equal("Test Heritage Title"))
// Check timeline_items
Expect(response).To(HaveKey("timeline_items"))
timelineItems := response["timeline_items"].([]interface{})
Expect(timelineItems).To(HaveLen(1))
item := timelineItems[0].(map[string]interface{})
Expect(item["id"]).To(Equal("pugachev_rebellion"))
Expect(item["title"]).To(Equal("Пугачёвское восстание"))
Expect(item["summary"]).To(Equal("Восстание казаков и крестьян против царского режима"))
Expect(item["icon_name"]).To(Equal("war"))
Expect(item["category"]).To(Equal("military"))
Expect(item["kind"]).To(Equal("historical"))
Expect(item["is_historical"]).To(BeTrue())
Expect(item["importance"]).To(Equal(float64(8)))
// Check arrays
Expect(item["locations"]).To(HaveLen(3))
Expect(item["actors"]).To(HaveLen(3))
Expect(item["related"]).To(HaveLen(1))
Expect(item["tags"]).To(HaveLen(3))
// Check time fields
Expect(item).To(HaveKey("time_from"))
Expect(item).To(HaveKey("time_to"))
// Check sources
Expect(response).To(HaveKey("sources"))
sources := response["sources"].([]interface{})
Expect(sources).To(HaveLen(1))
})
})
Context("with no heritage data", func() {
It("should return empty heritage data structure", func() {
req, _ := http.NewRequest("GET", "/heritage", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).NotTo(HaveOccurred())
// Should have the correct structure even with no data
Expect(response).To(HaveKey("title"))
Expect(response).To(HaveKey("timeline_items"))
Expect(response).To(HaveKey("sources"))
timelineItems := response["timeline_items"].([]interface{})
Expect(timelineItems).To(HaveLen(0))
sources := response["sources"].([]interface{})
Expect(sources).To(HaveLen(0))
})
})
Context("with timeline items filtered by heritage flag", func() {
BeforeEach(func() {
// Create heritage timeline item
heritageItem := &domain.TimelineItem{
ID: "heritage_event",
Title: "Heritage Event",
Heritage: sql.NullBool{Bool: true, Valid: true},
Order: 1,
}
Expect(db.Create(heritageItem).Error).To(BeNil())
// Create non-heritage timeline item
nonHeritageItem := &domain.TimelineItem{
ID: "non_heritage_event",
Title: "Non-Heritage Event",
Heritage: sql.NullBool{Bool: false, Valid: true},
Order: 2,
}
Expect(db.Create(nonHeritageItem).Error).To(BeNil())
})
It("should only return heritage timeline items", func() {
req, _ := http.NewRequest("GET", "/heritage", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).NotTo(HaveOccurred())
timelineItems := response["timeline_items"].([]interface{})
Expect(timelineItems).To(HaveLen(1))
item := timelineItems[0].(map[string]interface{})
Expect(item["id"]).To(Equal("heritage_event"))
Expect(item["heritage"]).To(BeTrue())
})
})
})
})

View File

@ -0,0 +1,444 @@
package handler
import (
"bugulma/backend/internal/service"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
)
// I18nHandler handles i18n API requests
type I18nHandler struct {
i18nService *service.I18nService
}
// NewI18nHandler creates a new i18n handler
func NewI18nHandler(i18nService *service.I18nService) *I18nHandler {
return &I18nHandler{
i18nService: i18nService,
}
}
// GetUITranslations returns UI translations for a locale
// @Summary Get UI translations
// @Tags i18n
// @Produce json
// @Param locale path string true "Locale code (en, tt)"
// @Success 200 {object} map[string]string
// @Router /api/i18n/ui/{locale} [get]
func (h *I18nHandler) GetUITranslations(c *gin.Context) {
locale := c.Param("locale")
if !h.i18nService.ValidateLocale(locale) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid locale"})
return
}
ctx := c.Request.Context()
// Get all UI translations for this locale from the database
// UI translations are stored with entityType="ui", entityID="ui"
localizations, err := h.i18nService.GetUITranslationsForLocale(ctx, locale)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to load UI translations: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{
"locale": locale,
"translations": localizations,
})
}
// GetDataTranslation returns a data translation
// @Summary Get data translation
// @Tags i18n
// @Produce json
// @Param entityType path string true "Entity type"
// @Param entityID path string true "Entity ID"
// @Param field path string true "Field name"
// @Param locale query string true "Locale code"
// @Success 200 {object} map[string]string
// @Router /api/i18n/data/{entityType}/{entityID}/{field} [get]
func (h *I18nHandler) GetDataTranslation(c *gin.Context) {
entityType := c.Param("entityType")
entityID := c.Param("entityID")
field := c.Param("field")
locale := c.Query("locale")
if locale == "" {
locale = service.DefaultLocale
}
if !h.i18nService.ValidateLocale(locale) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid locale"})
return
}
// GetDataTranslation returns only a string (no error), so we need to handle it differently
// For now, we'll need the source text as fallback - this should come from the entity
// In a real scenario, you'd fetch the entity first to get the source text
translated := h.i18nService.GetDataTranslation(
c.Request.Context(),
entityType,
entityID,
field,
locale,
"", // Fallback would need to be provided from entity source
)
c.JSON(http.StatusOK, gin.H{
"entity_type": entityType,
"entity_id": entityID,
"field": field,
"locale": locale,
"value": translated,
})
}
// GetSupportedLocales returns all supported locales
// @Summary Get supported locales
// @Tags i18n
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /api/i18n/locales [get]
func (h *I18nHandler) GetSupportedLocales(c *gin.Context) {
locales, err := h.i18nService.GetSupportedLocales(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"locales": locales,
"default_locale": service.DefaultLocale,
"supported": service.SupportedLocales,
})
}
// GetTranslationStats returns translation statistics
// @Summary Get translation statistics
// @Tags i18n
// @Produce json
// @Param entityType query string false "Entity type filter"
// @Success 200 {object} map[string]interface{}
// @Router /api/i18n/stats [get]
func (h *I18nHandler) GetTranslationStats(c *gin.Context) {
entityType := c.Query("entityType")
stats, err := h.i18nService.GetTranslationStats(c.Request.Context(), entityType)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}
// Admin endpoints for i18n management
// UpdateUITranslation updates a UI translation (admin only)
// @Summary Update UI translation
// @Tags admin
// @Accept json
// @Produce json
// @Param locale path string true "Locale code"
// @Param key path string true "Translation key"
// @Param request body UpdateUITranslationRequest true "Translation value"
// @Success 200 {object} map[string]string
// @Router /api/v1/admin/i18n/ui/:locale/:key [put]
func (h *I18nHandler) UpdateUITranslation(c *gin.Context) {
locale := c.Param("locale")
key := c.Param("key")
if !h.i18nService.ValidateLocale(locale) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid locale"})
return
}
var req UpdateUITranslationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.i18nService.SetUITranslation(c.Request.Context(), key, locale, req.Value); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Translation updated"})
}
type UpdateUITranslationRequest struct {
Value string `json:"value" binding:"required"`
}
// BulkUpdateUITranslations updates multiple UI translations (admin only)
// @Summary Bulk update UI translations
// @Tags admin
// @Accept json
// @Produce json
// @Param request body BulkUpdateUITranslationsRequest true "Bulk update request"
// @Success 200 {object} map[string]string
// @Router /api/v1/admin/i18n/ui/bulk-update [post]
func (h *I18nHandler) BulkUpdateUITranslations(c *gin.Context) {
var req BulkUpdateUITranslationsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updates := make(map[string]map[string]string) // locale -> key -> value
for _, update := range req.Updates {
if updates[update.Locale] == nil {
updates[update.Locale] = make(map[string]string)
}
updates[update.Locale][update.Key] = update.Value
}
for locale, keys := range updates {
if !h.i18nService.ValidateLocale(locale) {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid locale: %s", locale)})
return
}
for key, value := range keys {
if err := h.i18nService.SetUITranslation(c.Request.Context(), key, locale, value); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to update %s:%s: %v", locale, key, err)})
return
}
}
}
c.JSON(http.StatusOK, gin.H{"message": "Translations updated"})
}
type BulkUpdateUITranslationsRequest struct {
Updates []TranslationUpdate `json:"updates" binding:"required"`
}
type TranslationUpdate struct {
Locale string `json:"locale" binding:"required"`
Key string `json:"key" binding:"required"`
Value string `json:"value" binding:"required"`
}
// AutoTranslateMissing auto-translates missing UI keys (admin only)
// @Summary Auto-translate missing keys
// @Tags admin
// @Accept json
// @Produce json
// @Param request body AutoTranslateRequest true "Auto-translate request"
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/admin/i18n/ui/auto-translate [post]
func (h *I18nHandler) AutoTranslateMissing(c *gin.Context) {
var req AutoTranslateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !h.i18nService.ValidateLocale(req.TargetLocale) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid target locale"})
return
}
// Get all UI keys for source locale
sourceTranslations, err := h.i18nService.GetUITranslationsForLocale(c.Request.Context(), req.SourceLocale)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Get existing target translations
targetTranslations, err := h.i18nService.GetUITranslationsForLocale(c.Request.Context(), req.TargetLocale)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Find missing keys
var keysToTranslate []string
for key := range sourceTranslations {
if _, exists := targetTranslations[key]; !exists {
keysToTranslate = append(keysToTranslate, key)
}
}
if len(keysToTranslate) == 0 {
c.JSON(http.StatusOK, gin.H{"message": "No missing translations", "translated": 0})
return
}
// Batch translate
translated, err := h.i18nService.BatchTranslateUI(c.Request.Context(), keysToTranslate, req.SourceLocale, req.TargetLocale)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Translations completed",
"translated": len(translated),
"results": translated,
})
}
type AutoTranslateRequest struct {
SourceLocale string `json:"sourceLocale" binding:"required"`
TargetLocale string `json:"targetLocale" binding:"required"`
}
// GetTranslationKeys returns all translation keys with status (admin only)
// @Summary Get translation keys with status
// @Tags admin
// @Produce json
// @Param locale path string true "Locale code"
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/admin/i18n/ui/:locale/keys [get]
func (h *I18nHandler) GetTranslationKeys(c *gin.Context) {
locale := c.Param("locale")
if !h.i18nService.ValidateLocale(locale) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid locale"})
return
}
// Get source locale (default: ru)
sourceLocale := service.DefaultLocale
sourceTranslations, err := h.i18nService.GetUITranslationsForLocale(c.Request.Context(), sourceLocale)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Get target locale translations
targetTranslations, err := h.i18nService.GetUITranslationsForLocale(c.Request.Context(), locale)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Build keys with status
keys := make([]map[string]interface{}, 0)
for key, sourceValue := range sourceTranslations {
status := "missing"
value := ""
if targetValue, exists := targetTranslations[key]; exists {
status = "translated"
value = targetValue
}
keys = append(keys, map[string]interface{}{
"key": key,
"source": sourceValue,
"value": value,
"status": status,
})
}
c.JSON(http.StatusOK, gin.H{
"locale": locale,
"keys": keys,
"total": len(keys),
"translated": len(targetTranslations),
"missing": len(sourceTranslations) - len(targetTranslations),
})
}
// UpdateDataTranslation updates a data translation (admin only)
// @Summary Update data translation
// @Tags admin
// @Accept json
// @Produce json
// @Param entityType path string true "Entity type"
// @Param entityID path string true "Entity ID"
// @Param field path string true "Field name"
// @Param locale path string true "Locale code"
// @Param request body UpdateDataTranslationRequest true "Translation value"
// @Success 200 {object} map[string]string
// @Router /api/v1/admin/i18n/data/:entityType/:entityID/:field/:locale [put]
func (h *I18nHandler) UpdateDataTranslation(c *gin.Context) {
entityType := c.Param("entityType")
entityID := c.Param("entityID")
field := c.Param("field")
locale := c.Param("locale")
if !h.i18nService.ValidateLocale(locale) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid locale"})
return
}
var req UpdateDataTranslationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.i18nService.SetDataTranslation(c.Request.Context(), entityType, entityID, field, locale, req.Value); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Translation updated"})
}
type UpdateDataTranslationRequest struct {
Value string `json:"value" binding:"required"`
}
// BulkTranslateData bulk translates entities (admin only)
// @Summary Bulk translate data
// @Tags admin
// @Accept json
// @Produce json
// @Param request body BulkTranslateDataRequest true "Bulk translate request"
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/admin/i18n/data/bulk-translate [post]
func (h *I18nHandler) BulkTranslateData(c *gin.Context) {
var req BulkTranslateDataRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !h.i18nService.ValidateLocale(req.TargetLocale) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid target locale"})
return
}
// TODO: Implement bulk data translation
c.JSON(http.StatusNotImplemented, gin.H{"error": "Bulk data translation not yet implemented"})
}
type BulkTranslateDataRequest struct {
EntityType string `json:"entityType" binding:"required"`
EntityIDs []string `json:"entityIDs" binding:"required"`
TargetLocale string `json:"targetLocale" binding:"required"`
Fields []string `json:"fields"`
}
// GetMissingTranslations returns entities with missing translations (admin only)
// @Summary Get entities with missing translations
// @Tags admin
// @Produce json
// @Param entityType path string true "Entity type"
// @Param locale query string true "Locale code"
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/admin/i18n/data/:entityType/missing [get]
func (h *I18nHandler) GetMissingTranslations(c *gin.Context) {
_ = c.Param("entityType") // Entity type - for future use
locale := c.Query("locale")
if locale == "" {
locale = service.DefaultLocale
}
if !h.i18nService.ValidateLocale(locale) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid locale"})
return
}
// TODO: Implement missing translations detection
c.JSON(http.StatusNotImplemented, gin.H{"error": "Missing translations detection not yet implemented"})
}

View File

@ -47,9 +47,12 @@ var _ = Describe("MatchingHandler", func() {
organizationRepo = repository.NewOrganizationRepository(db)
siteRepo = repository.NewSiteRepository(db)
negotiationRepo := repository.NewNegotiationHistoryRepository(db)
productRepo := repository.NewProductRepository(db)
serviceRepo := repository.NewServiceRepository(db)
communityListingRepo := repository.NewCommunityListingRepository(db)
cacheService := service.NewMemoryCacheService()
matchingService = matching.NewService(matchRepo, negotiationRepo, resourceRepo, siteRepo, organizationRepo, nil, nil, nil, nil)
matchingService = matching.NewService(matchRepo, negotiationRepo, resourceRepo, siteRepo, organizationRepo, productRepo, serviceRepo, communityListingRepo, nil, nil, nil, nil)
matchingHandler = handler.NewMatchingHandler(matchingService, cacheService)
router = gin.New()

View File

@ -0,0 +1,166 @@
package handler
import (
"net/http"
"bugulma/backend/internal/domain"
"bugulma/backend/internal/service"
"github.com/gin-gonic/gin"
)
// OrganizationAdminHandler handles admin-specific organization operations
type OrganizationAdminHandler struct {
orgHandler *OrganizationHandler
verificationService *service.VerificationService
}
func NewOrganizationAdminHandler(orgHandler *OrganizationHandler, verificationService *service.VerificationService) *OrganizationAdminHandler {
return &OrganizationAdminHandler{
orgHandler: orgHandler,
verificationService: verificationService,
}
}
// GetVerificationQueue returns organizations pending verification (admin only)
// @Summary Get verification queue
// @Tags admin
// @Produce json
// @Param status query string false "Filter by status"
// @Param dataType query string false "Filter by data type"
// @Param organizationId query string false "Filter by organization ID"
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/admin/organizations/verification-queue [get]
func (h *OrganizationAdminHandler) GetVerificationQueue(c *gin.Context) {
filters := service.VerificationQueueFilters{}
if statusStr := c.Query("status"); statusStr != "" {
status := domain.VerificationStatus(statusStr)
filters.Status = &status
}
if dataType := c.Query("dataType"); dataType != "" {
filters.DataType = &dataType
}
if orgID := c.Query("organizationId"); orgID != "" {
filters.OrganizationID = &orgID
}
queue, err := h.verificationService.GetVerificationQueue(c.Request.Context(), filters)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"queue": queue})
}
// VerifyOrganization verifies an organization (admin only)
// @Summary Verify organization
// @Tags admin
// @Accept json
// @Produce json
// @Param id path string true "Organization ID"
// @Param request body VerifyOrganizationRequest true "Verification request"
// @Success 200 {object} map[string]string
// @Router /api/v1/admin/organizations/:id/verify [post]
func (h *OrganizationAdminHandler) VerifyOrganization(c *gin.Context) {
orgID := c.Param("id")
var req VerifyOrganizationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID, _ := c.Get("user_id")
verifiedBy := userID.(string)
if err := h.verificationService.VerifyOrganization(c.Request.Context(), orgID, verifiedBy, req.Notes); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Organization verified"})
}
type VerifyOrganizationRequest struct {
Notes string `json:"notes"`
}
// RejectVerification rejects an organization verification (admin only)
// @Summary Reject verification
// @Tags admin
// @Accept json
// @Produce json
// @Param id path string true "Organization ID"
// @Param request body RejectVerificationRequest true "Rejection request"
// @Success 200 {object} map[string]string
// @Router /api/v1/admin/organizations/:id/reject [post]
func (h *OrganizationAdminHandler) RejectVerification(c *gin.Context) {
orgID := c.Param("id")
var req RejectVerificationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.verificationService.RejectVerification(c.Request.Context(), orgID, req.Reason, req.Notes); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Verification rejected"})
}
type RejectVerificationRequest struct {
Reason string `json:"reason" binding:"required"`
Notes string `json:"notes"`
}
// BulkVerify verifies multiple organizations (admin only)
// @Summary Bulk verify organizations
// @Tags admin
// @Accept json
// @Produce json
// @Param request body BulkVerifyRequest true "Bulk verify request"
// @Success 200 {object} map[string]string
// @Router /api/v1/admin/organizations/bulk-verify [post]
func (h *OrganizationAdminHandler) BulkVerify(c *gin.Context) {
var req BulkVerifyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID, _ := c.Get("user_id")
verifiedBy := userID.(string)
if err := h.verificationService.BulkVerify(c.Request.Context(), req.OrganizationIDs, verifiedBy); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Organizations verified"})
}
type BulkVerifyRequest struct {
OrganizationIDs []string `json:"organizationIds" binding:"required"`
}
// GetOrganizationStats returns organization statistics (admin only)
// @Summary Get organization statistics
// @Tags admin
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/admin/organizations/stats [get]
func (h *OrganizationAdminHandler) GetOrganizationStats(c *gin.Context) {
// TODO: Implement organization statistics
c.JSON(http.StatusOK, gin.H{
"total": 0,
"verified": 0,
"pending": 0,
"unverified": 0,
"new_this_month": 0,
})
}

View File

@ -1,6 +1,7 @@
package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
@ -19,17 +20,68 @@ type OrganizationHandler struct {
imageService *service.ImageService
resourceFlowService *service.ResourceFlowService
matchingService *matching.Service
proposalService *service.ProposalService
}
func NewOrganizationHandler(orgService *service.OrganizationService, imageService *service.ImageService, resourceFlowService *service.ResourceFlowService, matchingService *matching.Service) *OrganizationHandler {
func NewOrganizationHandler(orgService *service.OrganizationService, imageService *service.ImageService, resourceFlowService *service.ResourceFlowService, matchingService *matching.Service, proposalService *service.ProposalService) *OrganizationHandler {
return &OrganizationHandler{
orgService: orgService,
imageService: imageService,
resourceFlowService: resourceFlowService,
matchingService: matchingService,
proposalService: proposalService,
}
}
// Helper methods for error responses
func (h *OrganizationHandler) errorResponse(c *gin.Context, status int, message string) {
c.JSON(status, gin.H{"error": message})
}
func (h *OrganizationHandler) internalError(c *gin.Context, err error) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
func (h *OrganizationHandler) notFound(c *gin.Context, resource string) {
c.JSON(http.StatusNotFound, gin.H{"error": resource + " not found"})
}
func (h *OrganizationHandler) badRequest(c *gin.Context, err error) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
// Helper to parse limit query parameter with validation
func (h *OrganizationHandler) parseLimitQuery(c *gin.Context, defaultLimit, maxLimit int) int {
limitStr := c.DefaultQuery("limit", strconv.Itoa(defaultLimit))
limit, err := strconv.Atoi(limitStr)
if err != nil || limit < 1 {
return defaultLimit
}
if limit > maxLimit {
return maxLimit
}
return limit
}
// Helper to get organization by ID or return error response
func (h *OrganizationHandler) getOrgByIDOrError(c *gin.Context, id string) (*domain.Organization, bool) {
org, err := h.orgService.GetByID(c.Request.Context(), id)
if err != nil {
h.notFound(c, "Organization")
return nil, false
}
return org, true
}
// Helper to convert subtypes to string slice
func subtypesToStrings(subtypes []domain.OrganizationSubtype) []string {
result := make([]string, len(subtypes))
for i, st := range subtypes {
result[i] = string(st)
}
return result
}
type CreateOrganizationRequest struct {
// Required fields
Name string `json:"name" binding:"required"`
@ -94,14 +146,21 @@ type CreateOrganizationRequest struct {
func (h *OrganizationHandler) Create(c *gin.Context) {
var req CreateOrganizationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
h.badRequest(c, err)
return
}
// Validate subtype
subtype := domain.OrganizationSubtype(req.Subtype)
if !domain.IsValidSubtype(subtype) {
h.errorResponse(c, http.StatusBadRequest, "invalid subtype: "+req.Subtype)
return
}
orgReq := service.CreateOrganizationRequest{
Name: req.Name,
Subtype: domain.OrganizationSubtype(req.Subtype),
Sector: req.Sector,
Subtype: subtype,
Sector: domain.OrganizationSector(req.Sector),
Description: req.Description,
LogoURL: req.LogoURL,
GalleryImages: req.GalleryImages,
@ -146,7 +205,7 @@ func (h *OrganizationHandler) Create(c *gin.Context) {
org, err := h.orgService.Create(c.Request.Context(), orgReq)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
h.internalError(c, err)
return
}
@ -155,13 +214,10 @@ func (h *OrganizationHandler) Create(c *gin.Context) {
func (h *OrganizationHandler) GetByID(c *gin.Context) {
id := c.Param("id")
org, err := h.orgService.GetByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Organization not found"})
org, ok := h.getOrgByIDOrError(c, id)
if !ok {
return
}
c.JSON(http.StatusOK, org)
}
@ -169,9 +225,9 @@ func (h *OrganizationHandler) GetAll(c *gin.Context) {
// Check for sector filter
sector := c.Query("sector")
if sector != "" {
orgs, err := h.orgService.GetBySector(c.Request.Context(), sector)
orgs, err := h.orgService.GetBySector(c.Request.Context(), domain.OrganizationSector(sector))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
h.internalError(c, err)
return
}
c.JSON(http.StatusOK, orgs)
@ -181,7 +237,7 @@ func (h *OrganizationHandler) GetAll(c *gin.Context) {
// No filter - return all organizations
orgs, err := h.orgService.GetAll(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
h.internalError(c, err)
return
}
@ -193,26 +249,32 @@ func (h *OrganizationHandler) Update(c *gin.Context) {
var req CreateOrganizationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
h.badRequest(c, err)
return
}
org, err := h.orgService.GetByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Organization not found"})
org, ok := h.getOrgByIDOrError(c, id)
if !ok {
return
}
// Validate subtype
subtype := domain.OrganizationSubtype(req.Subtype)
if !domain.IsValidSubtype(subtype) {
h.errorResponse(c, http.StatusBadRequest, "invalid subtype: "+req.Subtype)
return
}
// Update fields
org.Name = req.Name
org.Sector = req.Sector
org.Subtype = domain.OrganizationSubtype(req.Subtype)
org.Sector = domain.OrganizationSector(req.Sector)
org.Subtype = subtype
org.Description = req.Description
org.LogoURL = req.LogoURL
org.Website = req.Website
if err := h.orgService.Update(c.Request.Context(), org); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
h.internalError(c, err)
return
}
@ -221,66 +283,75 @@ func (h *OrganizationHandler) Update(c *gin.Context) {
func (h *OrganizationHandler) Delete(c *gin.Context) {
id := c.Param("id")
if err := h.orgService.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Organization not found"})
h.notFound(c, "Organization")
return
}
c.JSON(http.StatusNoContent, nil)
}
func (h *OrganizationHandler) GetBySubtype(c *gin.Context) {
subtype := c.Param("subtype")
orgs, err := h.orgService.GetBySubtype(c.Request.Context(), domain.OrganizationSubtype(subtype))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
h.internalError(c, err)
return
}
c.JSON(http.StatusOK, orgs)
}
func (h *OrganizationHandler) GetBySector(c *gin.Context) {
sector := c.Param("sector")
sectorParam := c.Param("sector")
sector := domain.OrganizationSector(sectorParam)
orgs, err := h.orgService.GetBySector(c.Request.Context(), sector)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
h.internalError(c, err)
return
}
c.JSON(http.StatusOK, orgs)
}
// GetSectorStats returns sector statistics (top sectors by organization count)
func (h *OrganizationHandler) GetSectorStats(c *gin.Context) { // force reload
// Get limit from query param, default to 10
limitStr := c.DefaultQuery("limit", "10")
limit, err := strconv.Atoi(limitStr)
if err != nil || limit < 1 || limit > 50 {
limit = 10
}
func (h *OrganizationHandler) GetSectorStats(c *gin.Context) {
limit := h.parseLimitQuery(c, 10, 50)
stats, err := h.orgService.GetSectorStats(c.Request.Context(), limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
h.internalError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"sectors": stats})
}
// GetAllSubtypes returns all available organization subtypes
func (h *OrganizationHandler) GetAllSubtypes(c *gin.Context) {
subtypes := domain.GetAllSubtypes()
c.JSON(http.StatusOK, gin.H{"subtypes": subtypesToStrings(subtypes)})
}
// GetSubtypesBySector returns subtypes filtered by sector
func (h *OrganizationHandler) GetSubtypesBySector(c *gin.Context) {
sectorParam := c.Query("sector")
if sectorParam == "" {
h.GetAllSubtypes(c)
return
}
c.JSON(http.StatusOK, gin.H{"sectors": stats})
// Parse sector parameter as OrganizationSector enum
sector := domain.OrganizationSector(sectorParam)
subtypes := domain.GetSubtypesBySector(sector)
c.JSON(http.StatusOK, gin.H{
"sector": sector,
"subtypes": subtypesToStrings(subtypes),
})
}
func (h *OrganizationHandler) GetByCertification(c *gin.Context) {
cert := c.Param("cert")
orgs, err := h.orgService.GetByCertification(c.Request.Context(), cert)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
h.internalError(c, err)
return
}
c.JSON(http.StatusOK, orgs)
}
@ -291,34 +362,22 @@ func (h *OrganizationHandler) GetByCertification(c *gin.Context) {
func (h *OrganizationHandler) Search(c *gin.Context) {
query := c.Query("q")
if query == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter 'q' is required"})
h.errorResponse(c, http.StatusBadRequest, "query parameter 'q' is required")
return
}
limit := 50 // Default limit
if limitStr := c.Query("limit"); limitStr != "" {
if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 {
limit = parsedLimit
if limit > 200 {
limit = 200 // Maximum limit
}
}
}
limit := h.parseLimitQuery(c, 50, 200)
orgs, err := h.orgService.Search(c.Request.Context(), query, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
h.internalError(c, err)
return
}
// Return structured response matching frontend schema
response := gin.H{
c.JSON(http.StatusOK, gin.H{
"organizations": orgs,
"count": len(orgs),
"total": len(orgs), // For now, we don't have total count from search
}
c.JSON(http.StatusOK, response)
})
}
// SearchSuggestions handles autocomplete/suggestion requests
@ -332,19 +391,10 @@ func (h *OrganizationHandler) SearchSuggestions(c *gin.Context) {
return
}
limit := 10 // Default limit
if limitStr := c.Query("limit"); limitStr != "" {
if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 {
limit = parsedLimit
if limit > 50 {
limit = 50 // Maximum limit
}
}
}
limit := h.parseLimitQuery(c, 10, 50)
suggestions, err := h.orgService.SearchSuggestions(c.Request.Context(), query, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
h.internalError(c, err)
return
}
@ -359,13 +409,13 @@ func (h *OrganizationHandler) GetNearby(c *gin.Context) {
}
if err := c.ShouldBindQuery(&query); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
h.badRequest(c, err)
return
}
orgs, err := h.orgService.GetWithinRadius(c.Request.Context(), query.Latitude, query.Longitude, query.RadiusKm)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
h.internalError(c, err)
return
}
@ -375,26 +425,13 @@ func (h *OrganizationHandler) GetNearby(c *gin.Context) {
// UploadLogo handles logo image uploads for organizations
func (h *OrganizationHandler) UploadLogo(c *gin.Context) {
orgID := c.Param("id")
// Get the uploaded file
file, header, err := c.Request.FormFile("logo")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "No logo file provided"})
return
}
defer file.Close()
// Save the image
uploadedImage, err := h.imageService.SaveImage(file, header, "logos")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to save logo: %v", err)})
uploadedImage, ok := h.handleImageUpload(c, orgID, "logo", "logos")
if !ok {
return
}
// Update the organization with the new logo URL
org, err := h.orgService.GetByID(c.Request.Context(), orgID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Organization not found"})
org, ok := h.getOrgByIDOrError(c, orgID)
if !ok {
return
}
@ -405,7 +442,7 @@ func (h *OrganizationHandler) UploadLogo(c *gin.Context) {
org.LogoURL = uploadedImage.URL
if err := h.orgService.Update(c.Request.Context(), org); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update organization"})
h.errorResponse(c, http.StatusInternalServerError, "Failed to update organization")
return
}
@ -422,7 +459,7 @@ func (h *OrganizationHandler) UploadGalleryImage(c *gin.Context) {
// Get the uploaded file
file, header, err := c.Request.FormFile("image")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "No image file provided"})
h.errorResponse(c, http.StatusBadRequest, "No image file provided")
return
}
defer file.Close()
@ -430,14 +467,13 @@ func (h *OrganizationHandler) UploadGalleryImage(c *gin.Context) {
// Save the image
uploadedImage, err := h.imageService.SaveImage(file, header, "gallery")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to save image: %v", err)})
h.errorResponse(c, http.StatusInternalServerError, fmt.Sprintf("Failed to save image: %v", err))
return
}
// Update the organization by adding the image to gallery
org, err := h.orgService.GetByID(c.Request.Context(), orgID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Organization not found"})
org, ok := h.getOrgByIDOrError(c, orgID)
if !ok {
return
}
@ -476,14 +512,13 @@ func (h *OrganizationHandler) DeleteGalleryImage(c *gin.Context) {
imageURL := c.Query("url")
if imageURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Image URL required"})
h.errorResponse(c, http.StatusBadRequest, "Image URL required")
return
}
// Get the organization
org, err := h.orgService.GetByID(c.Request.Context(), orgID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Organization not found"})
org, ok := h.getOrgByIDOrError(c, orgID)
if !ok {
return
}
@ -511,7 +546,7 @@ func (h *OrganizationHandler) DeleteGalleryImage(c *gin.Context) {
galleryJSON, _ := json.Marshal(galleryImages)
org.GalleryImages = datatypes.JSON(galleryJSON)
if err := h.orgService.Update(c.Request.Context(), org); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update organization"})
h.errorResponse(c, http.StatusInternalServerError, "Failed to update organization")
return
}
@ -524,171 +559,140 @@ func (h *OrganizationHandler) DeleteGalleryImage(c *gin.Context) {
// GetSimilarOrganizations returns organizations similar to the given organization
func (h *OrganizationHandler) GetSimilarOrganizations(c *gin.Context) {
orgID := c.Param("id")
limitStr := c.DefaultQuery("limit", "5")
limit := h.parseLimitQuery(c, 5, 50)
limit := 5
if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 {
limit = parsedLimit
}
// For now, return organizations of the same sector/type
// TODO: Implement more sophisticated similarity algorithm
org, err := h.orgService.GetByID(c.Request.Context(), orgID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Organization not found"})
org, ok := h.getOrgByIDOrError(c, orgID)
if !ok {
return
}
// Get organizations by sector
similarOrgs, err := h.orgService.GetBySector(c.Request.Context(), org.Sector)
// Get organizations by sector (primary similarity factor)
sectorOrgs, err := h.orgService.GetBySector(c.Request.Context(), org.Sector)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get similar organizations"})
h.errorResponse(c, http.StatusInternalServerError, "Failed to get similar organizations")
return
}
// Filter out the original organization and limit results
var result []*domain.Organization
for _, similarOrg := range similarOrgs {
if similarOrg.ID != orgID && len(result) < limit {
result = append(result, similarOrg)
}
// Get resource flows for the organization to find complementary organizations
orgFlows, err := h.resourceFlowService.GetByOrganizationID(c.Request.Context(), orgID)
if err != nil {
// Continue without resource flow matching if it fails
orgFlows = []*domain.ResourceFlow{}
}
c.JSON(http.StatusOK, result)
// Calculate similarity scores using service layer
similarOrgs, err := h.orgService.CalculateSimilarityScores(c.Request.Context(), orgID, sectorOrgs, orgFlows)
if err != nil {
h.errorResponse(c, http.StatusInternalServerError, "Failed to calculate similarity scores")
return
}
// Limit results
if len(similarOrgs) > limit {
similarOrgs = similarOrgs[:limit]
}
c.JSON(http.StatusOK, similarOrgs)
}
// GetOrganizationProposals returns proposals related to the organization
func (h *OrganizationHandler) GetOrganizationProposals(c *gin.Context) {
// orgID := c.Param("id") // TODO: Use when implementing proposals functionality
orgID := c.Param("id")
if h.proposalService == nil {
h.errorResponse(c, http.StatusServiceUnavailable, "Proposal service is not available")
return
}
// TODO: Implement proposals functionality
// For now, return empty array
c.JSON(http.StatusOK, []interface{}{})
proposals, err := h.proposalService.GetByOrganizationID(c.Request.Context(), orgID)
if err != nil {
h.errorResponse(c, http.StatusInternalServerError, "Failed to get organization proposals")
return
}
c.JSON(http.StatusOK, proposals)
}
// GetOrganizationResources returns resource flows for the organization
func (h *OrganizationHandler) GetOrganizationResources(c *gin.Context) {
orgID := c.Param("id")
// Get resource flows by organization ID
resourceFlows, err := h.resourceFlowService.GetByOrganizationID(c.Request.Context(), orgID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get organization resources"})
h.errorResponse(c, http.StatusInternalServerError, "Failed to get organization resources")
return
}
c.JSON(http.StatusOK, resourceFlows)
}
// DirectSymbiosisMatch represents a direct symbiosis match
type DirectSymbiosisMatch struct {
PartnerID string `json:"partner_id"`
PartnerName string `json:"partner_name"`
Resource string `json:"resource"`
ResourceFlowID string `json:"resource_flow_id"`
// GetOrganizationProducts returns products for the organization
func (h *OrganizationHandler) GetOrganizationProducts(c *gin.Context) {
orgID := c.Param("id")
if h.matchingService == nil {
h.errorResponse(c, http.StatusServiceUnavailable, "Matching service is not available")
return
}
ctx := c.Request.Context()
products, err := h.matchingService.GetProductsByOrganization(ctx, orgID)
if err != nil {
h.errorResponse(c, http.StatusInternalServerError, "Failed to get organization products")
return
}
matches, err := h.convertItemsToDiscoveryMatches(ctx, products, "product")
if err != nil {
h.errorResponse(c, http.StatusInternalServerError, "Failed to convert products to matches")
return
}
c.JSON(http.StatusOK, matches)
}
// DirectSymbiosisResponse represents the response for direct symbiosis matches
type DirectSymbiosisResponse struct {
Providers []DirectSymbiosisMatch `json:"providers"`
Consumers []DirectSymbiosisMatch `json:"consumers"`
// GetOrganizationServices returns services for the organization
func (h *OrganizationHandler) GetOrganizationServices(c *gin.Context) {
orgID := c.Param("id")
if h.matchingService == nil {
h.errorResponse(c, http.StatusServiceUnavailable, "Matching service is not available")
return
}
ctx := c.Request.Context()
services, err := h.matchingService.GetServicesByOrganization(ctx, orgID)
if err != nil {
h.errorResponse(c, http.StatusInternalServerError, "Failed to get organization services")
return
}
matches, err := h.convertItemsToDiscoveryMatches(ctx, services, "service")
if err != nil {
h.errorResponse(c, http.StatusInternalServerError, "Failed to convert services to matches")
return
}
c.JSON(http.StatusOK, matches)
}
// GetDirectMatches returns direct matches for the organization
func (h *OrganizationHandler) GetDirectMatches(c *gin.Context) {
orgID := c.Param("id")
if orgID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Organization ID is required"})
h.errorResponse(c, http.StatusBadRequest, "Organization ID is required")
return
}
ctx := c.Request.Context()
// Get organization's resource flows
resourceFlows, err := h.resourceFlowService.GetByOrganizationID(ctx, orgID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to get resource flows: %v", err)})
h.errorResponse(c, http.StatusInternalServerError, fmt.Sprintf("Failed to get resource flows: %v", err))
return
}
var providers []DirectSymbiosisMatch
var consumers []DirectSymbiosisMatch
// Separate flows by direction
var inputFlows []*domain.ResourceFlow // What this org needs (consumes)
var outputFlows []*domain.ResourceFlow // What this org provides (produces)
for _, flow := range resourceFlows {
if flow.Direction == domain.DirectionInput {
inputFlows = append(inputFlows, flow)
} else if flow.Direction == domain.DirectionOutput {
outputFlows = append(outputFlows, flow)
}
providers, consumers, err := h.orgService.FindDirectMatches(ctx, orgID, resourceFlows, 10)
if err != nil {
h.errorResponse(c, http.StatusInternalServerError, "Failed to find direct matches")
return
}
// Find providers: organizations that can provide what this org needs
for _, inputFlow := range inputFlows {
// Find organizations that have output flows of the same type
matchingOutputs, err := h.resourceFlowService.GetByTypeAndDirection(ctx, inputFlow.Type, domain.DirectionOutput)
if err != nil {
continue
}
for _, outputFlow := range matchingOutputs {
// Skip if it's the same organization
if outputFlow.OrganizationID == orgID {
continue
}
// Get organization info
org, err := h.orgService.GetByID(ctx, outputFlow.OrganizationID)
if err != nil {
continue
}
providers = append(providers, DirectSymbiosisMatch{
PartnerID: outputFlow.OrganizationID,
PartnerName: org.Name,
Resource: string(outputFlow.Type),
ResourceFlowID: outputFlow.ID,
})
}
}
// Find consumers: organizations that need what this org provides
for _, outputFlow := range outputFlows {
// Find organizations that have input flows of the same type
matchingInputs, err := h.resourceFlowService.GetByTypeAndDirection(ctx, outputFlow.Type, domain.DirectionInput)
if err != nil {
continue
}
for _, inputFlow := range matchingInputs {
// Skip if it's the same organization
if inputFlow.OrganizationID == orgID {
continue
}
// Get organization info
org, err := h.orgService.GetByID(ctx, inputFlow.OrganizationID)
if err != nil {
continue
}
consumers = append(consumers, DirectSymbiosisMatch{
PartnerID: inputFlow.OrganizationID,
PartnerName: org.Name,
Resource: string(inputFlow.Type),
ResourceFlowID: inputFlow.ID,
})
}
}
// Remove duplicates and limit results
providers = h.deduplicateMatches(providers, 10)
consumers = h.deduplicateMatches(consumers, 10)
response := DirectSymbiosisResponse{
response := service.DirectSymbiosisResponse{
Providers: providers,
Consumers: consumers,
}
@ -696,20 +700,88 @@ func (h *OrganizationHandler) GetDirectMatches(c *gin.Context) {
c.JSON(http.StatusOK, response)
}
// deduplicateMatches removes duplicate matches and limits the number of results
func (h *OrganizationHandler) deduplicateMatches(matches []DirectSymbiosisMatch, limit int) []DirectSymbiosisMatch {
seen := make(map[string]bool)
var result []DirectSymbiosisMatch
// convertItemsToDiscoveryMatches converts products or services to DiscoveryMatch format
func (h *OrganizationHandler) convertItemsToDiscoveryMatches(ctx context.Context, items interface{}, matchType string) ([]*matching.DiscoveryMatch, error) {
var matches []*matching.DiscoveryMatch
for _, match := range matches {
key := match.PartnerID + ":" + match.Resource
if !seen[key] && len(result) < limit {
seen[key] = true
result = append(result, match)
switch matchType {
case "product":
products, ok := items.([]*domain.Product)
if !ok {
return nil, fmt.Errorf("invalid products type")
}
for _, product := range products {
var org *domain.Organization
var site *domain.Site
if product.OrganizationID != "" {
org, _ = h.orgService.GetByID(ctx, product.OrganizationID)
}
// Note: Would need site service/repo access - for now, skip site
match := &matching.DiscoveryMatch{
Product: product,
MatchType: "product",
RelevanceScore: 1.0,
TextMatchScore: 1.0,
CategoryMatchScore: 1.0,
DistanceScore: 1.0,
PriceMatchScore: 1.0,
AvailabilityScore: 1.0,
Organization: org,
Site: site,
}
matches = append(matches, match)
}
case "service":
services, ok := items.([]*domain.Service)
if !ok {
return nil, fmt.Errorf("invalid services type")
}
for _, service := range services {
var org *domain.Organization
var site *domain.Site
if service.OrganizationID != "" {
org, _ = h.orgService.GetByID(ctx, service.OrganizationID)
}
// Note: Would need site service/repo access - for now, skip site
match := &matching.DiscoveryMatch{
Service: service,
MatchType: "service",
RelevanceScore: 1.0,
TextMatchScore: 1.0,
CategoryMatchScore: 1.0,
DistanceScore: 1.0,
PriceMatchScore: 1.0,
AvailabilityScore: 1.0,
Organization: org,
Site: site,
}
matches = append(matches, match)
}
default:
return nil, fmt.Errorf("unsupported match type: %s", matchType)
}
return result
return matches, nil
}
// handleImageUpload handles common image upload logic
func (h *OrganizationHandler) handleImageUpload(c *gin.Context, orgID, formField, folderName string) (*service.UploadedImage, bool) {
file, header, err := c.Request.FormFile(formField)
if err != nil {
h.errorResponse(c, http.StatusBadRequest, "No "+formField+" file provided")
return nil, false
}
defer file.Close()
uploadedImage, err := h.imageService.SaveImage(file, header, folderName)
if err != nil {
h.errorResponse(c, http.StatusInternalServerError, fmt.Sprintf("Failed to save %s: %v", formField, err))
return nil, false
}
return uploadedImage, true
}
// GetUserOrganizations returns organizations associated with the current user
@ -720,17 +792,15 @@ func (h *OrganizationHandler) GetUserOrganizations(c *gin.Context) {
// Get user ID from context (set by middleware)
_, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
h.errorResponse(c, http.StatusUnauthorized, "User not authenticated")
return
}
// TODO: In future, implement user-organization relationship table
// For now, return all organizations as a temporary solution
// This allows the frontend to work while we develop proper user-org relationships
organizations, err := h.orgService.GetAll(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to get organizations: %v", err)})
h.errorResponse(c, http.StatusInternalServerError, fmt.Sprintf("Failed to get organizations: %v", err))
return
}

View File

@ -38,7 +38,7 @@ var _ = Describe("OrganizationHandler", func() {
orgService = service.NewOrganizationService(orgRepo, nil) // No graph repo for tests
resourceFlowRepo := repository.NewResourceFlowRepository(db)
resourceFlowService := service.NewResourceFlowService(resourceFlowRepo, nil)
orgHandler = handler.NewOrganizationHandler(orgService, nil, resourceFlowService) // No image service for tests
orgHandler = handler.NewOrganizationHandler(orgService, nil, resourceFlowService, nil, nil) // No image/matching/proposal services for tests
router = gin.New()
router.POST("/organizations", orgHandler.Create)
@ -60,8 +60,8 @@ var _ = Describe("OrganizationHandler", func() {
It("should create an organization", func() {
reqBody := handler.CreateOrganizationRequest{
Name: "Test Org",
Sector: "Manufacturing",
Subtype: "commercial",
Sector: "manufacturing",
Subtype: "factory",
Description: "Test Description",
}
body, _ := json.Marshal(reqBody)
@ -169,15 +169,15 @@ var _ = Describe("OrganizationHandler", func() {
org := &domain.Organization{
ID: "org-1",
Name: "Original Name",
Sector: "Manufacturing",
Subtype: domain.SubtypeCommercial,
Sector: "manufacturing",
Subtype: domain.SubtypeFactory,
}
Expect(orgRepo.Create(context.TODO(), org)).To(Succeed())
updateReq := map[string]interface{}{
"name": "Updated Name",
"sector": "Technology",
"subtype": "commercial",
"sector": "technology",
"subtype": "it_services",
}
jsonData, _ := json.Marshal(updateReq)
@ -193,18 +193,18 @@ var _ = Describe("OrganizationHandler", func() {
updated, err := orgRepo.GetByID(context.Background(), "org-1")
Expect(err).NotTo(HaveOccurred())
Expect(updated.Name).To(Equal("Updated Name"))
Expect(updated.Sector).To(Equal("Technology"))
Expect(updated.Sector).To(Equal(domain.SectorTechnology))
})
})
Describe("GetBySubtype", func() {
It("should get organizations by subtype", func() {
org1 := &domain.Organization{ID: "org-1", Name: "Org 1", Subtype: domain.SubtypeCommercial}
org1 := &domain.Organization{ID: "org-1", Name: "Org 1", Subtype: domain.SubtypeConsultant}
org2 := &domain.Organization{ID: "org-2", Name: "Org 2", Subtype: domain.SubtypeGovernment}
Expect(orgRepo.Create(context.TODO(), org1)).To(Succeed())
Expect(orgRepo.Create(context.TODO(), org2)).To(Succeed())
req, _ := http.NewRequest("GET", "/organizations/subtype/commercial", nil)
req, _ := http.NewRequest("GET", "/organizations/subtype/consultant", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@ -220,12 +220,12 @@ var _ = Describe("OrganizationHandler", func() {
Describe("GetBySector", func() {
It("should get organizations by sector", func() {
org1 := &domain.Organization{ID: "org-1", Name: "Org 1", Sector: "Manufacturing"}
org2 := &domain.Organization{ID: "org-2", Name: "Org 2", Sector: "Technology"}
org1 := &domain.Organization{ID: "org-1", Name: "Org 1", Sector: domain.SectorManufacturing}
org2 := &domain.Organization{ID: "org-2", Name: "Org 2", Sector: domain.SectorTechnology}
Expect(orgRepo.Create(context.TODO(), org1)).To(Succeed())
Expect(orgRepo.Create(context.TODO(), org2)).To(Succeed())
req, _ := http.NewRequest("GET", "/organizations/sector/Manufacturing", nil)
req, _ := http.NewRequest("GET", "/organizations/sector/manufacturing", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

View File

@ -55,7 +55,7 @@ func (h *ProposalHandler) GetByID(c *gin.Context) {
// GetByOrganizationID returns proposals for an organization
func (h *ProposalHandler) GetByOrganizationID(c *gin.Context) {
orgID := c.Param("id")
orgID := c.Param("orgId")
if orgID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Organization ID is required"})
return

View File

@ -114,6 +114,12 @@ func (h *ResourceFlowHandler) Update(c *gin.Context) {
func (h *ResourceFlowHandler) GetByID(c *gin.Context) {
id := c.Param("id")
// If no ID is provided, treat as list request
if id == "" {
h.List(c)
return
}
rf, err := h.resourceService.GetByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Resource flow not found"})
@ -123,6 +129,17 @@ func (h *ResourceFlowHandler) GetByID(c *gin.Context) {
c.JSON(http.StatusOK, rf)
}
// List retrieves all resource flows
func (h *ResourceFlowHandler) List(c *gin.Context) {
flows, err := h.resourceService.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list resource flows"})
return
}
c.JSON(http.StatusOK, flows)
}
func (h *ResourceFlowHandler) GetBySite(c *gin.Context) {
siteID := c.Param("siteId")

View File

@ -0,0 +1,401 @@
package handler
import (
"net/http"
"strconv"
"bugulma/backend/internal/domain"
"bugulma/backend/internal/service"
"github.com/gin-gonic/gin"
)
type SubscriptionHandler struct {
subscriptionService *service.SubscriptionService
}
func NewSubscriptionHandler(subscriptionService *service.SubscriptionService) *SubscriptionHandler {
return &SubscriptionHandler{
subscriptionService: subscriptionService,
}
}
// GetSubscription returns the current user's subscription
// @Summary Get current subscription
// @Tags subscription
// @Produce json
// @Success 200 {object} domain.Subscription
// @Router /api/v1/subscription [get]
func (h *SubscriptionHandler) GetSubscription(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
subscription, err := h.subscriptionService.GetSubscription(c.Request.Context(), userID.(string))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, subscription)
}
// CreateSubscription creates a new subscription
// @Summary Create subscription
// @Tags subscription
// @Accept json
// @Produce json
// @Param request body CreateSubscriptionRequest true "Subscription request"
// @Success 201 {object} domain.Subscription
// @Router /api/v1/subscription [post]
func (h *SubscriptionHandler) CreateSubscription(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var req CreateSubscriptionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
subscription, err := h.subscriptionService.CreateSubscription(
c.Request.Context(),
userID.(string),
domain.SubscriptionPlan(req.Plan),
domain.BillingPeriod(req.BillingPeriod),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, subscription)
}
type CreateSubscriptionRequest struct {
Plan string `json:"plan" binding:"required"`
BillingPeriod string `json:"billingPeriod" binding:"required"`
}
// GetPlans returns available subscription plans
// @Summary Get available plans
// @Tags subscription
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/subscription/plans [get]
func (h *SubscriptionHandler) GetPlans(c *gin.Context) {
plans := map[string]interface{}{
"free": map[string]interface{}{
"id": "free",
"name": "Free",
"description": "Perfect for getting started",
"price": map[string]int{
"monthly": 0,
"yearly": 0,
},
"features": []string{},
"limits": map[string]interface{}{
"organizations": 3,
"users": 1,
"storage": 100,
"apiCalls": 1000,
},
},
"basic": map[string]interface{}{
"id": "basic",
"name": "Basic",
"description": "For small businesses",
"price": map[string]int{
"monthly": 29,
"yearly": 290,
},
"features": []string{"team_collaboration"},
"limits": map[string]interface{}{
"organizations": 10,
"users": 5,
"storage": 1000,
"apiCalls": 10000,
},
},
"professional": map[string]interface{}{
"id": "professional",
"name": "Professional",
"description": "For growing businesses",
"price": map[string]int{
"monthly": 99,
"yearly": 990,
},
"features": []string{
"unlimited_organizations",
"advanced_analytics",
"api_access",
"team_collaboration",
"priority_support",
},
"limits": map[string]interface{}{
"organizations": -1,
"users": 20,
"storage": 10000,
"apiCalls": 100000,
},
"popular": true,
},
"enterprise": map[string]interface{}{
"id": "enterprise",
"name": "Enterprise",
"description": "For large organizations",
"price": map[string]int{
"monthly": 499,
"yearly": 4990,
},
"features": []string{
"unlimited_organizations",
"advanced_analytics",
"api_access",
"custom_domain",
"sso",
"team_collaboration",
"dedicated_support",
"white_label",
},
"limits": map[string]interface{}{
"organizations": -1,
"users": -1,
"storage": -1,
"apiCalls": -1,
"customDomains": 5,
},
},
}
c.JSON(http.StatusOK, gin.H{"plans": plans})
}
// UpgradeSubscription upgrades a subscription
// @Summary Upgrade subscription
// @Tags subscription
// @Accept json
// @Produce json
// @Param request body UpgradeSubscriptionRequest true "Upgrade request"
// @Success 200 {object} domain.Subscription
// @Router /api/v1/subscription/upgrade [post]
func (h *SubscriptionHandler) UpgradeSubscription(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var req UpgradeSubscriptionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
subscription, err := h.subscriptionService.GetSubscription(c.Request.Context(), userID.(string))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
updated, err := h.subscriptionService.UpdateSubscription(
c.Request.Context(),
subscription.ID,
domain.SubscriptionPlan(req.Plan),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, updated)
}
type UpgradeSubscriptionRequest struct {
Plan string `json:"plan" binding:"required"`
}
// CancelSubscription cancels a subscription
// @Summary Cancel subscription
// @Tags subscription
// @Success 200 {object} map[string]string
// @Router /api/v1/subscription/cancel [post]
func (h *SubscriptionHandler) CancelSubscription(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
subscription, err := h.subscriptionService.GetSubscription(c.Request.Context(), userID.(string))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := h.subscriptionService.CancelSubscription(c.Request.Context(), subscription.ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Subscription canceled"})
}
// GetInvoices returns invoices for the current user
// @Summary Get invoices
// @Tags subscription
// @Produce json
// @Param limit query int false "Limit" default(10)
// @Param offset query int false "Offset" default(0)
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/subscription/invoices [get]
func (h *SubscriptionHandler) GetInvoices(c *gin.Context) {
// TODO: Implement invoice retrieval
c.JSON(http.StatusOK, gin.H{"invoices": []interface{}{}, "total": 0})
}
// GetPaymentMethods returns payment methods for the current user
// @Summary Get payment methods
// @Tags subscription
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/subscription/payment-methods [get]
func (h *SubscriptionHandler) GetPaymentMethods(c *gin.Context) {
// TODO: Implement payment method retrieval
c.JSON(http.StatusOK, gin.H{"paymentMethods": []interface{}{}})
}
// AddPaymentMethod adds a payment method
// @Summary Add payment method
// @Tags subscription
// @Accept json
// @Produce json
// @Param request body AddPaymentMethodRequest true "Payment method request"
// @Success 201 {object} domain.PaymentMethod
// @Router /api/v1/subscription/payment-methods [post]
func (h *SubscriptionHandler) AddPaymentMethod(c *gin.Context) {
// TODO: Implement payment method addition
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented"})
}
type AddPaymentMethodRequest struct {
Type string `json:"type" binding:"required"`
}
// Webhook handles payment provider webhooks
// @Summary Handle webhook
// @Tags subscription
// @Accept json
// @Produce json
// @Success 200 {object} map[string]string
// @Router /api/v1/subscription/webhook [post]
func (h *SubscriptionHandler) Webhook(c *gin.Context) {
// TODO: Implement webhook handling
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented"})
}
// GetUsageStats returns usage statistics for the current user
// @Summary Get usage statistics
// @Tags subscription
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/subscription/usage [get]
func (h *SubscriptionHandler) GetUsageStats(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
stats, err := h.subscriptionService.GetUsageStats(c.Request.Context(), userID.(string))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"usage": stats})
}
// CheckFeature checks if user has access to a feature
// @Summary Check feature access
// @Tags subscription
// @Produce json
// @Param feature query string true "Feature name"
// @Success 200 {object} map[string]bool
// @Router /api/v1/subscription/check-feature [get]
func (h *SubscriptionHandler) CheckFeature(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
feature := c.Query("feature")
if feature == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Feature parameter required"})
return
}
hasAccess, err := h.subscriptionService.CheckFeatureAccess(
c.Request.Context(),
userID.(string),
service.SubscriptionFeature(feature),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"hasAccess": hasAccess})
}
// CheckLimits checks if user is within limits
// @Summary Check limits
// @Tags subscription
// @Produce json
// @Param limitType query string true "Limit type"
// @Param current query int true "Current usage"
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/subscription/check-limits [get]
func (h *SubscriptionHandler) CheckLimits(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
limitTypeStr := c.Query("limitType")
currentStr := c.Query("current")
if limitTypeStr == "" || currentStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "limitType and current parameters required"})
return
}
current, err := strconv.ParseInt(currentStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid current value"})
return
}
withinLimits, remaining, err := h.subscriptionService.CheckLimits(
c.Request.Context(),
userID.(string),
domain.UsageLimitType(limitTypeStr),
current,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"withinLimits": withinLimits,
"remaining": remaining,
})
}

View File

@ -0,0 +1,335 @@
package handler
import (
"net/http"
"strconv"
"bugulma/backend/internal/domain"
"bugulma/backend/internal/service"
"github.com/gin-gonic/gin"
)
type UserHandler struct {
userService *service.UserService
}
func NewUserHandler(userService *service.UserService) *UserHandler {
return &UserHandler{
userService: userService,
}
}
// ListUsers lists users with filters and pagination (admin only)
// @Summary List users
// @Tags admin
// @Produce json
// @Param role query string false "Filter by role"
// @Param isActive query bool false "Filter by active status"
// @Param search query string false "Search in email and name"
// @Param limit query int false "Limit" default(25)
// @Param offset query int false "Offset" default(0)
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/admin/users [get]
func (h *UserHandler) ListUsers(c *gin.Context) {
filters := domain.UserListFilters{}
// Parse role filter
if roleStr := c.Query("role"); roleStr != "" {
role := domain.UserRole(roleStr)
filters.Role = &role
}
// Parse isActive filter
if isActiveStr := c.Query("isActive"); isActiveStr != "" {
isActive := isActiveStr == "true"
filters.IsActive = &isActive
}
// Parse search
filters.Search = c.Query("search")
// Parse pagination
limit := 25
offset := 0
if limitStr := c.Query("limit"); limitStr != "" {
if parsed, err := strconv.Atoi(limitStr); err == nil {
limit = parsed
}
}
if offsetStr := c.Query("offset"); offsetStr != "" {
if parsed, err := strconv.Atoi(offsetStr); err == nil {
offset = parsed
}
}
pagination := domain.PaginationParams{
Limit: limit,
Offset: offset,
}
result, err := h.userService.ListUsers(c.Request.Context(), filters, pagination)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"users": result.Items,
"total": result.Total,
"limit": limit,
"offset": offset,
})
}
// GetUser gets a user by ID (admin only)
// @Summary Get user
// @Tags admin
// @Produce json
// @Param id path string true "User ID"
// @Success 200 {object} domain.User
// @Router /api/v1/admin/users/:id [get]
func (h *UserHandler) GetUser(c *gin.Context) {
userID := c.Param("id")
user, err := h.userService.GetUser(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
// Don't return password
user.Password = ""
c.JSON(http.StatusOK, user)
}
// CreateUser creates a new user (admin only)
// @Summary Create user
// @Tags admin
// @Accept json
// @Produce json
// @Param request body CreateUserRequest true "User request"
// @Success 201 {object} domain.User
// @Router /api/v1/admin/users [post]
func (h *UserHandler) CreateUser(c *gin.Context) {
var req CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
createReq := service.CreateUserRequest{
Email: req.Email,
Name: req.Name,
Password: req.Password,
Role: domain.UserRole(req.Role),
Permissions: req.Permissions,
}
user, err := h.userService.CreateUser(c.Request.Context(), createReq)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Don't return password
user.Password = ""
c.JSON(http.StatusCreated, user)
}
type CreateUserRequest struct {
Email string `json:"email" binding:"required,email"`
Name string `json:"name" binding:"required"`
Password string `json:"password" binding:"required,min=8"`
Role string `json:"role" binding:"required"`
Permissions []string `json:"permissions"`
}
// UpdateUser updates a user (admin only)
// @Summary Update user
// @Tags admin
// @Accept json
// @Produce json
// @Param id path string true "User ID"
// @Param request body UpdateUserRequest true "Update request"
// @Success 200 {object} domain.User
// @Router /api/v1/admin/users/:id [put]
func (h *UserHandler) UpdateUser(c *gin.Context) {
userID := c.Param("id")
var req UpdateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updateReq := service.UpdateUserRequest{}
if req.Name != nil {
updateReq.Name = req.Name
}
if req.Email != nil {
updateReq.Email = req.Email
}
if req.Role != nil {
role := domain.UserRole(*req.Role)
updateReq.Role = &role
}
if req.Permissions != nil {
updateReq.Permissions = req.Permissions
}
if req.IsActive != nil {
updateReq.IsActive = req.IsActive
}
user, err := h.userService.UpdateUser(c.Request.Context(), userID, updateReq)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Don't return password
user.Password = ""
c.JSON(http.StatusOK, user)
}
type UpdateUserRequest struct {
Name *string `json:"name"`
Email *string `json:"email"`
Role *string `json:"role"`
Permissions *[]string `json:"permissions"`
IsActive *bool `json:"isActive"`
}
// UpdateRole updates a user's role (admin only)
// @Summary Update user role
// @Tags admin
// @Accept json
// @Produce json
// @Param id path string true "User ID"
// @Param request body UpdateRoleRequest true "Role request"
// @Success 200 {object} map[string]string
// @Router /api/v1/admin/users/:id/role [patch]
func (h *UserHandler) UpdateRole(c *gin.Context) {
userID := c.Param("id")
var req UpdateRoleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.userService.UpdateRole(c.Request.Context(), userID, domain.UserRole(req.Role)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Role updated"})
}
type UpdateRoleRequest struct {
Role string `json:"role" binding:"required"`
}
// UpdatePermissions updates a user's permissions (admin only)
// @Summary Update user permissions
// @Tags admin
// @Accept json
// @Produce json
// @Param id path string true "User ID"
// @Param request body UpdatePermissionsRequest true "Permissions request"
// @Success 200 {object} map[string]string
// @Router /api/v1/admin/users/:id/permissions [patch]
func (h *UserHandler) UpdatePermissions(c *gin.Context) {
userID := c.Param("id")
var req UpdatePermissionsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.userService.UpdatePermissions(c.Request.Context(), userID, req.Permissions); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Permissions updated"})
}
type UpdatePermissionsRequest struct {
Permissions []string `json:"permissions" binding:"required"`
}
// DeactivateUser deactivates a user (admin only)
// @Summary Deactivate user
// @Tags admin
// @Success 200 {object} map[string]string
// @Router /api/v1/admin/users/:id [delete]
func (h *UserHandler) DeactivateUser(c *gin.Context) {
userID := c.Param("id")
if err := h.userService.DeactivateUser(c.Request.Context(), userID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "User deactivated"})
}
// GetUserActivity gets activity log for a user (admin only)
// @Summary Get user activity
// @Tags admin
// @Produce json
// @Param id path string true "User ID"
// @Param limit query int false "Limit" default(50)
// @Param offset query int false "Offset" default(0)
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/admin/users/:id/activity [get]
func (h *UserHandler) GetUserActivity(c *gin.Context) {
userID := c.Param("id")
limit := 50
offset := 0
if limitStr := c.Query("limit"); limitStr != "" {
if parsed, err := strconv.Atoi(limitStr); err == nil {
limit = parsed
}
}
if offsetStr := c.Query("offset"); offsetStr != "" {
if parsed, err := strconv.Atoi(offsetStr); err == nil {
offset = parsed
}
}
activities, total, err := h.userService.GetUserActivity(c.Request.Context(), userID, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"activities": activities,
"total": total,
"limit": limit,
"offset": offset,
})
}
// GetUserStats gets user statistics (admin only)
// @Summary Get user statistics
// @Tags admin
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/admin/users/stats [get]
func (h *UserHandler) GetUserStats(c *gin.Context) {
stats, err := h.userService.GetUserStats(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}

View File

@ -0,0 +1,71 @@
package localization
import (
"bugulma/backend/internal/domain"
"fmt"
"gorm.io/gorm"
)
// GetFieldValue is a generic function to get a field value from an entity using its handler
func GetFieldValue[T any](entity T, field string, handler domain.EntityHandler[T]) string {
return handler.GetFieldValue(entity, field)
}
// GetEntityID is a generic function to get an entity ID using its handler
func GetEntityID[T any](entity T, handler domain.EntityHandler[T]) string {
return handler.GetEntityID(entity)
}
// LoadEntities is a generic function to load entities using their handler
func LoadEntities[T any](db *gorm.DB, handler domain.EntityHandler[T], options LoadOptions) ([]T, error) {
return handler.LoadEntities(db, domain.EntityLoadOptions{IncludeAllSites: options.IncludeAllSites})
}
// FindExistingTranslationInDB searches for existing translations in the database
// This is a generic version that works with any entity type
func FindExistingTranslationInDB[T any](db *gorm.DB, entityType, field, targetLocale, russianText string, handler domain.EntityHandler[T]) string {
if russianText == "" {
return ""
}
normalized := fmt.Sprintf("%%%s%%", russianText)
// Query: Find entities of the same type with similar Russian text in the same field
// that already have a translation to the target locale
query := fmt.Sprintf(`
SELECT l.value
FROM localizations l
WHERE l.entity_type = '%s'
AND l.field = '%s'
AND l.locale = '%s'
AND EXISTS (
SELECT 1 FROM localizations l2
WHERE l2.entity_type = l.entity_type
AND l2.entity_id = l.entity_id
AND l2.field = l.field
AND l2.locale = 'ru'
AND l2.value LIKE '%s'
)
LIMIT 1
`, entityType, field, targetLocale, normalized)
var translation string
err := db.Raw(query).Scan(&translation).Error
if err != nil || translation == "" {
return ""
}
return translation
}
// GetRussianContent is a generic wrapper that gets field values from entities
func GetRussianContent[T any](entity T, field string, handler domain.EntityHandler[T]) string {
return handler.GetFieldValue(entity, field)
}
// HasRussianContent checks if an entity field has content
func HasRussianContent[T any](entity T, field string, handler domain.EntityHandler[T]) bool {
value := handler.GetFieldValue(entity, field)
return value != ""
}

View File

@ -0,0 +1,60 @@
package handlers
import (
"bugulma/backend/internal/domain"
"gorm.io/gorm"
)
// geographicalFeatureHandler implements EntityHandler for GeographicalFeature entities
type geographicalFeatureHandler struct{}
func NewGeographicalFeatureHandler() domain.EntityHandler[*domain.GeographicalFeature] {
return &geographicalFeatureHandler{}
}
func (h *geographicalFeatureHandler) GetEntityID(entity *domain.GeographicalFeature) string {
return entity.ID
}
func (h *geographicalFeatureHandler) GetFieldValue(entity *domain.GeographicalFeature, field string) string {
switch field {
case "name":
return entity.Name
case "properties":
// For properties JSON field, return it as string for potential translation
// In practice, individual properties within the JSON might be translatable
return string(entity.Properties)
default:
return ""
}
}
func (h *geographicalFeatureHandler) GetLocalizableFields() []string {
return []string{"name", "properties"}
}
func (h *geographicalFeatureHandler) LoadEntities(db *gorm.DB, options domain.EntityLoadOptions) ([]*domain.GeographicalFeature, error) {
var features []*domain.GeographicalFeature
if err := db.Find(&features).Error; err != nil {
return nil, err
}
return features, nil
}
func (h *geographicalFeatureHandler) BuildFieldQuery(db *gorm.DB, field, value string) *gorm.DB {
switch field {
case "name":
return db.Where("name = ?", value)
case "properties":
// For JSON field, we might need to use JSON operators
// For now, use simple text search
return db.Where("properties::text LIKE ?", "%"+value+"%")
default:
return db
}
}
func (h *geographicalFeatureHandler) GetEntityType() string {
return "geographical_feature"
}

View File

@ -0,0 +1,52 @@
package handlers
import (
"bugulma/backend/internal/domain"
"gorm.io/gorm"
)
// heritageSourceHandler implements EntityHandler for HeritageSource entities
type heritageSourceHandler struct{}
func NewHeritageSourceHandler() domain.EntityHandler[*domain.HeritageSource] {
return &heritageSourceHandler{}
}
func (h *heritageSourceHandler) GetEntityID(entity *domain.HeritageSource) string {
return entity.GetEntityID()
}
func (h *heritageSourceHandler) GetFieldValue(entity *domain.HeritageSource, field string) string {
switch field {
case "title":
return entity.Title
default:
return ""
}
}
func (h *heritageSourceHandler) GetLocalizableFields() []string {
return []string{"title"}
}
func (h *heritageSourceHandler) LoadEntities(db *gorm.DB, options domain.EntityLoadOptions) ([]*domain.HeritageSource, error) {
var sources []*domain.HeritageSource
if err := db.Order(`"order" ASC`).Find(&sources).Error; err != nil {
return nil, err
}
return sources, nil
}
func (h *heritageSourceHandler) BuildFieldQuery(db *gorm.DB, field, value string) *gorm.DB {
switch field {
case "title":
return db.Where("title = ?", value)
default:
return db
}
}
func (h *heritageSourceHandler) GetEntityType() string {
return "heritage_source"
}

View File

@ -0,0 +1,56 @@
package handlers
import (
"bugulma/backend/internal/domain"
"gorm.io/gorm"
)
// heritageTitleHandler implements EntityHandler for HeritageTitle entities
type heritageTitleHandler struct{}
func NewHeritageTitleHandler() domain.EntityHandler[*domain.HeritageTitle] {
return &heritageTitleHandler{}
}
func (h *heritageTitleHandler) GetEntityID(entity *domain.HeritageTitle) string {
return entity.GetEntityID()
}
func (h *heritageTitleHandler) GetFieldValue(entity *domain.HeritageTitle, field string) string {
switch field {
case "title":
return entity.Title
case "content":
return entity.Content
default:
return ""
}
}
func (h *heritageTitleHandler) GetLocalizableFields() []string {
return []string{"title", "content"}
}
func (h *heritageTitleHandler) LoadEntities(db *gorm.DB, options domain.EntityLoadOptions) ([]*domain.HeritageTitle, error) {
var titles []*domain.HeritageTitle
if err := db.Find(&titles).Error; err != nil {
return nil, err
}
return titles, nil
}
func (h *heritageTitleHandler) BuildFieldQuery(db *gorm.DB, field, value string) *gorm.DB {
switch field {
case "title":
return db.Where("title = ?", value)
case "content":
return db.Where("content = ?", value)
default:
return db
}
}
func (h *heritageTitleHandler) GetEntityType() string {
return "heritage_title"
}

View File

@ -0,0 +1,64 @@
package handlers
import (
"bugulma/backend/internal/domain"
"gorm.io/gorm"
)
// organizationHandler implements EntityHandler for Organization entities
type organizationHandler struct{}
func NewOrganizationHandler() domain.EntityHandler[*domain.Organization] {
return &organizationHandler{}
}
func (h *organizationHandler) GetEntityID(entity *domain.Organization) string {
return entity.GetEntityID()
}
func (h *organizationHandler) GetFieldValue(entity *domain.Organization, field string) string {
switch field {
case "name":
return entity.Name
case "description":
return entity.Description
case "sector":
return string(entity.Sector)
case "sub_type":
return string(entity.Subtype)
default:
return ""
}
}
func (h *organizationHandler) GetLocalizableFields() []string {
return []string{"name", "description", "sector", "sub_type"}
}
func (h *organizationHandler) LoadEntities(db *gorm.DB, options domain.EntityLoadOptions) ([]*domain.Organization, error) {
var organizations []*domain.Organization
if err := db.Find(&organizations).Error; err != nil {
return nil, err
}
return organizations, nil
}
func (h *organizationHandler) BuildFieldQuery(db *gorm.DB, field, value string) *gorm.DB {
switch field {
case "name":
return db.Where("name = ?", value)
case "description":
return db.Where("description = ?", value)
case "sector":
return db.Where("sector = ?", value)
case "sub_type":
return db.Where("subtype = ?", value)
default:
return db
}
}
func (h *organizationHandler) GetEntityType() string {
return "organization"
}

View File

@ -0,0 +1,56 @@
package handlers
import (
"bugulma/backend/internal/domain"
"gorm.io/gorm"
)
// productHandler implements EntityHandler for Product entities
type productHandler struct{}
func NewProductHandler() domain.EntityHandler[*domain.Product] {
return &productHandler{}
}
func (h *productHandler) GetEntityID(entity *domain.Product) string {
return entity.ID
}
func (h *productHandler) GetFieldValue(entity *domain.Product, field string) string {
switch field {
case "name":
return entity.Name
case "description":
return entity.Description
default:
return ""
}
}
func (h *productHandler) GetLocalizableFields() []string {
return []string{"name", "description"}
}
func (h *productHandler) LoadEntities(db *gorm.DB, options domain.EntityLoadOptions) ([]*domain.Product, error) {
var products []*domain.Product
if err := db.Find(&products).Error; err != nil {
return nil, err
}
return products, nil
}
func (h *productHandler) BuildFieldQuery(db *gorm.DB, field, value string) *gorm.DB {
switch field {
case "name":
return db.Where("name = ?", value)
case "description":
return db.Where("description = ?", value)
default:
return db
}
}
func (h *productHandler) GetEntityType() string {
return "product"
}

View File

@ -0,0 +1,97 @@
package handlers
import (
"bugulma/backend/internal/domain"
"gorm.io/gorm"
)
// siteHandler implements EntityHandler for Site entities
type siteHandler struct{}
func NewSiteHandler() domain.EntityHandler[*domain.Site] {
return &siteHandler{}
}
func (h *siteHandler) GetEntityID(entity *domain.Site) string {
return entity.ID
}
func (h *siteHandler) GetFieldValue(entity *domain.Site, field string) string {
switch field {
case "name":
return entity.Name
case "notes":
return entity.Notes
case "builder_owner":
return entity.BuilderOwner
case "architect":
return entity.Architect
case "original_purpose":
return entity.OriginalPurpose
case "current_use":
return entity.CurrentUse
case "style":
return entity.Style
case "materials":
return entity.Materials
default:
return ""
}
}
func (h *siteHandler) GetLocalizableFields() []string {
return []string{
"name",
"notes",
"builder_owner",
"architect",
"original_purpose",
"current_use",
"style",
"materials",
}
}
func (h *siteHandler) LoadEntities(db *gorm.DB, options domain.EntityLoadOptions) ([]*domain.Site, error) {
var sites []*domain.Site
query := db.Model(&domain.Site{})
// Filter by heritage status unless --all-sites flag is set
if !options.IncludeAllSites {
query = query.Where("heritage_status IS NOT NULL AND heritage_status != ''")
}
if err := query.Find(&sites).Error; err != nil {
return nil, err
}
return sites, nil
}
func (h *siteHandler) BuildFieldQuery(db *gorm.DB, field, value string) *gorm.DB {
switch field {
case "name":
return db.Where("name = ?", value)
case "notes":
return db.Where("notes = ?", value)
case "builder_owner":
return db.Where("builder_owner = ?", value)
case "architect":
return db.Where("architect = ?", value)
case "original_purpose":
return db.Where("original_purpose = ?", value)
case "current_use":
return db.Where("current_use = ?", value)
case "style":
return db.Where("style = ?", value)
case "materials":
return db.Where("materials = ?", value)
default:
return db
}
}
func (h *siteHandler) GetEntityType() string {
return "site"
}

View File

@ -0,0 +1,56 @@
package handlers
import (
"bugulma/backend/internal/domain"
"gorm.io/gorm"
)
// timelineItemHandler implements EntityHandler for TimelineItem entities
type timelineItemHandler struct{}
func NewTimelineItemHandler() domain.EntityHandler[*domain.TimelineItem] {
return &timelineItemHandler{}
}
func (h *timelineItemHandler) GetEntityID(entity *domain.TimelineItem) string {
return entity.GetEntityID()
}
func (h *timelineItemHandler) GetFieldValue(entity *domain.TimelineItem, field string) string {
switch field {
case "title":
return entity.Title
case "content":
return entity.Content
default:
return ""
}
}
func (h *timelineItemHandler) GetLocalizableFields() []string {
return []string{"title", "content"}
}
func (h *timelineItemHandler) LoadEntities(db *gorm.DB, options domain.EntityLoadOptions) ([]*domain.TimelineItem, error) {
var items []*domain.TimelineItem
if err := db.Order(`"order" ASC`).Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (h *timelineItemHandler) BuildFieldQuery(db *gorm.DB, field, value string) *gorm.DB {
switch field {
case "title":
return db.Where("title = ?", value)
case "content":
return db.Where("content = ?", value)
default:
return db
}
}
func (h *timelineItemHandler) GetEntityType() string {
return "timeline_item"
}

View File

@ -0,0 +1,9 @@
package localization
import "bugulma/backend/internal/domain"
// LoadOptions is an alias for domain.EntityLoadOptions to maintain backward compatibility
type LoadOptions = domain.EntityLoadOptions
// EntityHandler is an alias for domain.EntityHandler to maintain backward compatibility
type EntityHandler[T any] = domain.EntityHandler[T]

View File

@ -0,0 +1,70 @@
package localization
import (
"bugulma/backend/internal/localization/handlers"
)
// RegisterAllEntities registers all entity handlers with the registry
// This is called from init() to avoid import cycles
func RegisterAllEntities() {
// Register all entity handlers
RegisterEntity(&EntityDescriptor{
Type: "site",
Fields: []string{"name", "notes", "builder_owner", "architect", "original_purpose", "current_use", "style", "materials"},
Handler: handlers.NewSiteHandler(),
TableName: "sites",
IDField: "id",
})
RegisterEntity(&EntityDescriptor{
Type: "heritage_title",
Fields: []string{"title", "content"},
Handler: handlers.NewHeritageTitleHandler(),
TableName: "heritage_title",
IDField: "id",
})
RegisterEntity(&EntityDescriptor{
Type: "timeline_item",
Fields: []string{"title", "content"},
Handler: handlers.NewTimelineItemHandler(),
TableName: "timeline_items",
IDField: "id",
})
RegisterEntity(&EntityDescriptor{
Type: "heritage_source",
Fields: []string{"title"},
Handler: handlers.NewHeritageSourceHandler(),
TableName: "heritage_sources",
IDField: "id",
})
RegisterEntity(&EntityDescriptor{
Type: "organization",
Fields: []string{"name", "description", "sector", "sub_type"},
Handler: handlers.NewOrganizationHandler(),
TableName: "organizations",
IDField: "id",
})
RegisterEntity(&EntityDescriptor{
Type: "geographical_feature",
Fields: []string{"name", "properties"},
Handler: handlers.NewGeographicalFeatureHandler(),
TableName: "geographical_features",
IDField: "id",
})
RegisterEntity(&EntityDescriptor{
Type: "product",
Fields: []string{"name", "description"},
Handler: handlers.NewProductHandler(),
TableName: "products",
IDField: "id",
})
}
func init() {
RegisterAllEntities()
}

View File

@ -0,0 +1,67 @@
package localization
// EntityDescriptor describes an entity type and its handler
// Uses type erasure to store handlers of different concrete types
type EntityDescriptor struct {
Type string
Fields []string
Handler any // Type-erased handler - will be cast to specific type when used
TableName string
IDField string
}
// EntityRegistry holds all registered entity descriptors
var EntityRegistry = make(map[string]*EntityDescriptor)
// RegisterEntity registers a new entity type with its handler
func RegisterEntity(descriptor *EntityDescriptor) {
EntityRegistry[descriptor.Type] = descriptor
}
// GetEntityDescriptor returns the descriptor for a given entity type
func GetEntityDescriptor(entityType string) (*EntityDescriptor, bool) {
desc, exists := EntityRegistry[entityType]
return desc, exists
}
// GetAllEntityTypes returns all registered entity types
func GetAllEntityTypes() []string {
types := make([]string, 0, len(EntityRegistry))
for entityType := range EntityRegistry {
types = append(types, entityType)
}
return types
}
// GetEntityTypesForFilter filters entity types based on the filter string
func GetEntityTypesForFilter(entityTypeFilter string) []string {
switch entityTypeFilter {
case "all":
return GetAllEntityTypes()
case "site", "sites", "building", "buildings":
return []string{"site"}
case "heritage_title", "title", "titles":
return []string{"heritage_title"}
case "heritage_timeline_item", "timeline", "timeline_item", "item":
return []string{"heritage_timeline_item"}
case "heritage_source", "source", "sources":
return []string{"heritage_source"}
case "organization", "organizations", "org", "orgs":
return []string{"organization"}
case "geographical_feature", "geographical_features", "geo", "geofeature":
return []string{"geographical_feature"}
case "product", "products":
return []string{"product"}
default:
return []string{entityTypeFilter}
}
}
// GetFieldsForEntityType returns the localizable fields for an entity type
func GetFieldsForEntityType(entityType string) []string {
if desc, exists := GetEntityDescriptor(entityType); exists {
return desc.Fields
}
return []string{}
}

View File

@ -0,0 +1,4 @@
package localization
// This file is kept for backward compatibility
// The actual interface definitions are in interfaces.go

View File

@ -0,0 +1,349 @@
package matching
import (
"math"
"strings"
"bugulma/backend/internal/domain"
"bugulma/backend/internal/geospatial"
)
// DiscoveryMatch represents a soft match for products/services (not ResourceFlows)
type DiscoveryMatch struct {
Product *domain.Product `json:"product,omitempty"`
Service *domain.Service `json:"service,omitempty"`
CommunityListing *domain.CommunityListing `json:"community_listing,omitempty"`
MatchType string `json:"match_type"` // "product", "service", "community"
RelevanceScore float64 `json:"relevance_score"` // 0-1 overall relevance
TextMatchScore float64 `json:"text_match_score"` // 0-1 text similarity
CategoryMatchScore float64 `json:"category_match_score"` // 0-1 category match
DistanceScore float64 `json:"distance_score"` // 0-1 distance (closer = higher)
PriceMatchScore float64 `json:"price_match_score"` // 0-1 price compatibility
AvailabilityScore float64 `json:"availability_score"` // 0-1 availability match
DistanceKm float64 `json:"distance_km"`
Organization *domain.Organization `json:"organization,omitempty"`
Site *domain.Site `json:"site,omitempty"`
}
// DiscoveryQuery represents a search query for products/services
type DiscoveryQuery struct {
Query string `json:"query"` // Natural language search
Categories []string `json:"categories,omitempty"` // Filter by categories
Location *geospatial.Point `json:"location,omitempty"` // Search center point
RadiusKm float64 `json:"radius_km"` // Search radius
MaxPrice *float64 `json:"max_price,omitempty"` // Maximum price filter
MinPrice *float64 `json:"min_price,omitempty"` // Minimum price filter
AvailabilityStatus string `json:"availability_status,omitempty"` // Filter by availability
Tags []string `json:"tags,omitempty"` // Filter by tags
Limit int `json:"limit"` // Max results
Offset int `json:"offset"` // Pagination offset
}
// DiscoveryMatcher handles soft matching for products/services
type DiscoveryMatcher struct {
geoCalc geospatial.Calculator
}
// NewDiscoveryMatcher creates a new discovery matcher
func NewDiscoveryMatcher() *DiscoveryMatcher {
return &DiscoveryMatcher{
geoCalc: geospatial.NewCalculatorWithDefaults(),
}
}
// ScoreProductMatch calculates relevance score for a product match
// Formula: soft_match_score = 0.3*text_match + 0.2*category_match + 0.2*distance_score
// - 0.15*price_match + 0.15*availability_score
func (dm *DiscoveryMatcher) ScoreProductMatch(
product *domain.Product,
query DiscoveryQuery,
org *domain.Organization,
site *domain.Site,
) (*DiscoveryMatch, error) {
match := &DiscoveryMatch{
Product: product,
MatchType: "product",
}
// 1. Text match (30% weight)
textScore := dm.calculateTextMatch(
query.Query,
product.Name+" "+product.Description+" "+product.SearchKeywords,
)
match.TextMatchScore = textScore
// 2. Category match (20% weight)
categoryScore := dm.calculateCategoryMatch(query.Categories, string(product.Category))
match.CategoryMatchScore = categoryScore
// 3. Distance score (20% weight)
var distanceScore float64 = 1.0
var distanceKm float64 = 0.0
if query.Location != nil && product.Location.Valid {
productPoint := geospatial.Point{
Latitude: product.Location.Latitude,
Longitude: product.Location.Longitude,
}
distanceResult, err := dm.geoCalc.CalculateDistance(*query.Location, productPoint)
if err == nil {
distanceKm = distanceResult.DistanceKm
// Distance score: closer = higher (max 50km)
if distanceKm <= query.RadiusKm {
distanceScore = 1.0 - (distanceKm / math.Max(query.RadiusKm, 50.0))
distanceScore = math.Max(0, math.Min(1, distanceScore))
} else {
distanceScore = 0.0 // Outside radius
}
}
}
match.DistanceScore = distanceScore
match.DistanceKm = distanceKm
// 4. Price match (15% weight)
priceScore := dm.calculatePriceMatch(
query.MinPrice,
query.MaxPrice,
product.UnitPrice,
)
match.PriceMatchScore = priceScore
// 5. Availability score (15% weight)
availabilityScore := dm.calculateAvailabilityScore(
query.AvailabilityStatus,
product.AvailabilityStatus,
)
match.AvailabilityScore = availabilityScore
// Calculate overall relevance score
match.RelevanceScore = 0.3*textScore + 0.2*categoryScore + 0.2*distanceScore +
0.15*priceScore + 0.15*availabilityScore
match.Organization = org
match.Site = site
return match, nil
}
// ScoreServiceMatch calculates relevance score for a service match
func (dm *DiscoveryMatcher) ScoreServiceMatch(
service *domain.Service,
query DiscoveryQuery,
org *domain.Organization,
site *domain.Site,
) (*DiscoveryMatch, error) {
match := &DiscoveryMatch{
Service: service,
MatchType: "service",
}
// 1. Text match (30% weight)
textScore := dm.calculateTextMatch(
query.Query,
service.Domain+" "+service.Description+" "+service.SearchKeywords,
)
match.TextMatchScore = textScore
// 2. Category match (20% weight) - using service type and domain
categories := []string{string(service.Type), service.Domain}
categoryScore := dm.calculateCategoryMatch(query.Categories, categories...)
match.CategoryMatchScore = categoryScore
// 3. Distance score (20% weight)
var distanceScore float64 = 1.0
var distanceKm float64 = 0.0
if query.Location != nil && service.ServiceLocation.Valid {
servicePoint := geospatial.Point{
Latitude: service.ServiceLocation.Latitude,
Longitude: service.ServiceLocation.Longitude,
}
distanceResult, err := dm.geoCalc.CalculateDistance(*query.Location, servicePoint)
if err == nil {
distanceKm = distanceResult.DistanceKm
// Check if within service area
if distanceKm <= service.ServiceAreaKm && distanceKm <= query.RadiusKm {
distanceScore = 1.0 - (distanceKm / math.Max(query.RadiusKm, service.ServiceAreaKm))
distanceScore = math.Max(0, math.Min(1, distanceScore))
} else {
distanceScore = 0.0 // Outside service area or search radius
}
}
}
match.DistanceScore = distanceScore
match.DistanceKm = distanceKm
// 4. Price match (15% weight) - using hourly rate
var priceScore float64 = 1.0
if query.MaxPrice != nil && service.HourlyRate > 0 {
if service.HourlyRate <= *query.MaxPrice {
if query.MinPrice != nil {
if service.HourlyRate >= *query.MinPrice {
priceScore = 1.0
} else {
priceScore = 0.0
}
} else {
priceScore = 1.0
}
} else {
priceScore = 0.0
}
}
match.PriceMatchScore = priceScore
// 5. Availability score (15% weight)
availabilityScore := dm.calculateAvailabilityScore(
query.AvailabilityStatus,
service.AvailabilityStatus,
)
match.AvailabilityScore = availabilityScore
// Calculate overall relevance score
match.RelevanceScore = 0.3*textScore + 0.2*categoryScore + 0.2*distanceScore +
0.15*priceScore + 0.15*availabilityScore
match.Organization = org
match.Site = site
return match, nil
}
// ScoreCommunityListingMatch calculates relevance score for a community listing match
func (dm *DiscoveryMatcher) ScoreCommunityListingMatch(
listing *domain.CommunityListing,
query DiscoveryQuery,
) (*DiscoveryMatch, error) {
match := &DiscoveryMatch{
CommunityListing: listing,
MatchType: "community",
}
// Similar scoring logic as products
textScore := dm.calculateTextMatch(
query.Query,
listing.Title+" "+listing.Description+" "+listing.SearchKeywords,
)
match.TextMatchScore = textScore
categoryScore := dm.calculateCategoryMatch(query.Categories, listing.Category)
match.CategoryMatchScore = categoryScore
var distanceScore float64 = 1.0
var distanceKm float64 = 0.0
if query.Location != nil && listing.Location.Valid {
listingPoint := geospatial.Point{
Latitude: listing.Location.Latitude,
Longitude: listing.Location.Longitude,
}
distanceResult, err := dm.geoCalc.CalculateDistance(*query.Location, listingPoint)
if err == nil {
distanceKm = distanceResult.DistanceKm
if distanceKm <= query.RadiusKm {
distanceScore = 1.0 - (distanceKm / math.Max(query.RadiusKm, 50.0))
distanceScore = math.Max(0, math.Min(1, distanceScore))
} else {
distanceScore = 0.0
}
}
}
match.DistanceScore = distanceScore
match.DistanceKm = distanceKm
var priceScore float64 = 1.0
if listing.Price != nil {
priceScore = dm.calculatePriceMatch(query.MinPrice, query.MaxPrice, *listing.Price)
}
match.PriceMatchScore = priceScore
availabilityScore := dm.calculateAvailabilityScore(
query.AvailabilityStatus,
listing.AvailabilityStatus,
)
match.AvailabilityScore = availabilityScore
match.RelevanceScore = 0.3*textScore + 0.2*categoryScore + 0.2*distanceScore +
0.15*priceScore + 0.15*availabilityScore
return match, nil
}
// Helper methods
func (dm *DiscoveryMatcher) calculateTextMatch(query, text string) float64 {
if query == "" {
return 1.0 // No query = match everything
}
queryLower := strings.ToLower(query)
textLower := strings.ToLower(text)
// Simple word-based matching
queryWords := strings.Fields(queryLower)
textWords := strings.Fields(textLower)
if len(queryWords) == 0 {
return 1.0
}
matches := 0
for _, qw := range queryWords {
for _, tw := range textWords {
if strings.Contains(tw, qw) || strings.Contains(qw, tw) {
matches++
break
}
}
}
return float64(matches) / float64(len(queryWords))
}
func (dm *DiscoveryMatcher) calculateCategoryMatch(queryCategories []string, itemCategories ...string) float64 {
if len(queryCategories) == 0 {
return 1.0 // No category filter = match everything
}
for _, qc := range queryCategories {
qcLower := strings.ToLower(qc)
for _, ic := range itemCategories {
icLower := strings.ToLower(ic)
if qcLower == icLower || strings.Contains(icLower, qcLower) {
return 1.0 // Exact or partial match
}
}
}
return 0.0 // No match
}
func (dm *DiscoveryMatcher) calculatePriceMatch(minPrice, maxPrice *float64, itemPrice float64) float64 {
if minPrice == nil && maxPrice == nil {
return 1.0 // No price filter
}
if maxPrice != nil && itemPrice > *maxPrice {
return 0.0 // Above max
}
if minPrice != nil && itemPrice < *minPrice {
return 0.0 // Below min
}
return 1.0 // Within range
}
func (dm *DiscoveryMatcher) calculateAvailabilityScore(queryStatus, itemStatus string) float64 {
if queryStatus == "" {
return 1.0 // No filter
}
if queryStatus == itemStatus {
return 1.0 // Exact match
}
// Partial matches
if queryStatus == "available" && (itemStatus == "available" || itemStatus == "limited") {
return 0.8
}
return 0.0 // No match
}

View File

@ -2,6 +2,8 @@ package matching
import (
"context"
"fmt"
"strings"
"bugulma/backend/internal/analysis/regulatory"
"bugulma/backend/internal/analysis/risk"
@ -21,13 +23,19 @@ type Service struct {
matchRepo domain.MatchRepository
// Dependencies for data access
resourceFlowRepo domain.ResourceFlowRepository
siteRepo domain.SiteRepository
orgRepo domain.OrganizationRepository
eventBus domain.EventBus
resourceFlowRepo domain.ResourceFlowRepository
siteRepo domain.SiteRepository
orgRepo domain.OrganizationRepository
productRepo domain.ProductRepository
serviceRepo domain.ServiceRepository
communityListingRepo domain.CommunityListingRepository
eventBus domain.EventBus
// Geospatial calculator
geoCalc geospatial.Calculator
// Discovery matcher for products/services
discoveryMatcher *DiscoveryMatcher
}
// EventBus is now defined in the domain package
@ -39,6 +47,9 @@ func NewService(
resourceFlowRepo domain.ResourceFlowRepository,
siteRepo domain.SiteRepository,
orgRepo domain.OrganizationRepository,
productRepo domain.ProductRepository,
serviceRepo domain.ServiceRepository,
communityListingRepo domain.CommunityListingRepository,
riskSvc *risk.Service,
transportSvc *transport.Service,
regulatorySvc *regulatory.Service,
@ -64,16 +75,23 @@ func NewService(
// Create geospatial calculator
geoCalc := geospatial.NewCalculatorWithDefaults()
// Create discovery matcher for products/services
discoveryMatcher := NewDiscoveryMatcher()
return &Service{
engine: eng,
manager: mgr,
pluginMgr: pluginMgr,
matchRepo: matchRepo,
resourceFlowRepo: resourceFlowRepo,
siteRepo: siteRepo,
orgRepo: orgRepo,
eventBus: eventBus,
geoCalc: geoCalc,
engine: eng,
manager: mgr,
pluginMgr: pluginMgr,
matchRepo: matchRepo,
resourceFlowRepo: resourceFlowRepo,
siteRepo: siteRepo,
orgRepo: orgRepo,
productRepo: productRepo,
serviceRepo: serviceRepo,
communityListingRepo: communityListingRepo,
eventBus: eventBus,
geoCalc: geoCalc,
discoveryMatcher: discoveryMatcher,
}
}
@ -290,3 +308,298 @@ func (s *Service) rerankCandidates(candidates []*engine.Candidate) []*engine.Can
// Import Criteria from engine package
type Criteria = engine.Criteria
// FindProductMatches finds products matching the discovery query
func (s *Service) FindProductMatches(ctx context.Context, query DiscoveryQuery) ([]*DiscoveryMatch, error) {
if s.productRepo == nil {
return nil, fmt.Errorf("product repository not available")
}
// Search products based on query
var products []*domain.Product
var err error
if query.Location != nil && query.RadiusKm > 0 {
products, err = s.productRepo.GetNearby(ctx, query.Location.Latitude, query.Location.Longitude, query.RadiusKm)
} else {
products, err = s.productRepo.GetAll(ctx)
}
if err != nil {
return nil, fmt.Errorf("failed to fetch products: %w", err)
}
// Score and rank matches
var matches []*DiscoveryMatch
for _, product := range products {
// Get organization and site
var org *domain.Organization
var site *domain.Site
if product.OrganizationID != "" {
org, _ = s.orgRepo.GetByID(ctx, product.OrganizationID)
}
if product.SiteID != nil {
site, _ = s.siteRepo.GetByID(ctx, *product.SiteID)
}
match, err := s.discoveryMatcher.ScoreProductMatch(product, query, org, site)
if err != nil {
continue
}
// Apply filters
if query.AvailabilityStatus != "" && product.AvailabilityStatus != query.AvailabilityStatus {
continue
}
if len(query.Categories) > 0 {
categoryMatched := false
for _, cat := range query.Categories {
if strings.EqualFold(cat, string(product.Category)) {
categoryMatched = true
break
}
}
if !categoryMatched {
continue
}
}
matches = append(matches, match)
}
// Sort by relevance score
for i := 0; i < len(matches); i++ {
for j := i + 1; j < len(matches); j++ {
if matches[i].RelevanceScore < matches[j].RelevanceScore {
matches[i], matches[j] = matches[j], matches[i]
}
}
}
// Apply pagination
start := query.Offset
end := start + query.Limit
if start >= len(matches) {
return []*DiscoveryMatch{}, nil
}
if end > len(matches) {
end = len(matches)
}
return matches[start:end], nil
}
// FindServiceMatches finds services matching the discovery query
func (s *Service) FindServiceMatches(ctx context.Context, query DiscoveryQuery) ([]*DiscoveryMatch, error) {
if s.serviceRepo == nil {
return nil, fmt.Errorf("service repository not available")
}
// Search services based on query
var services []*domain.Service
var err error
if query.Location != nil && query.RadiusKm > 0 {
services, err = s.serviceRepo.GetNearby(ctx, query.Location.Latitude, query.Location.Longitude, query.RadiusKm)
} else {
services, err = s.serviceRepo.GetAll(ctx)
}
if err != nil {
return nil, fmt.Errorf("failed to fetch services: %w", err)
}
// Score and rank matches
var matches []*DiscoveryMatch
for _, service := range services {
// Get organization and site
var org *domain.Organization
var site *domain.Site
if service.OrganizationID != "" {
org, _ = s.orgRepo.GetByID(ctx, service.OrganizationID)
}
if service.SiteID != nil {
site, _ = s.siteRepo.GetByID(ctx, *service.SiteID)
}
match, err := s.discoveryMatcher.ScoreServiceMatch(service, query, org, site)
if err != nil {
continue
}
// Apply filters
if query.AvailabilityStatus != "" && service.AvailabilityStatus != query.AvailabilityStatus {
continue
}
matches = append(matches, match)
}
// Sort by relevance score
for i := 0; i < len(matches); i++ {
for j := i + 1; j < len(matches); j++ {
if matches[i].RelevanceScore < matches[j].RelevanceScore {
matches[i], matches[j] = matches[j], matches[i]
}
}
}
// Apply pagination
start := query.Offset
end := start + query.Limit
if start >= len(matches) {
return []*DiscoveryMatch{}, nil
}
if end > len(matches) {
end = len(matches)
}
return matches[start:end], nil
}
// UniversalSearch performs a unified search across resources, products, services, and community listings
func (s *Service) UniversalSearch(ctx context.Context, query DiscoveryQuery) (*UniversalSearchResult, error) {
result := &UniversalSearchResult{
Query: query,
}
// Search products (soft match)
if productMatches, err := s.FindProductMatches(ctx, query); err == nil {
result.ProductMatches = productMatches
}
// Search services (soft match)
if serviceMatches, err := s.FindServiceMatches(ctx, query); err == nil {
result.ServiceMatches = serviceMatches
}
// Search community listings (soft match)
if s.communityListingRepo != nil {
var listings []*domain.CommunityListing
var err error
if query.Location != nil && query.RadiusKm > 0 {
listings, err = s.communityListingRepo.GetNearby(ctx, query.Location.Latitude, query.Location.Longitude, query.RadiusKm)
} else {
listings, err = s.communityListingRepo.GetAll(ctx)
}
if err == nil {
for _, listing := range listings {
match, err := s.discoveryMatcher.ScoreCommunityListingMatch(listing, query)
if err == nil {
result.CommunityMatches = append(result.CommunityMatches, match)
}
}
// Sort community matches
for i := 0; i < len(result.CommunityMatches); i++ {
for j := i + 1; j < len(result.CommunityMatches); j++ {
if result.CommunityMatches[i].RelevanceScore < result.CommunityMatches[j].RelevanceScore {
result.CommunityMatches[i], result.CommunityMatches[j] = result.CommunityMatches[j], result.CommunityMatches[i]
}
}
}
}
}
// Note: Resource flow matches (hard match) would be handled separately via FindMatches
// This is the "soft match" layer as per concept's layered architecture
return result, nil
}
// UniversalSearchResult contains results from universal search
type UniversalSearchResult struct {
Query DiscoveryQuery `json:"query"`
ProductMatches []*DiscoveryMatch `json:"product_matches"`
ServiceMatches []*DiscoveryMatch `json:"service_matches"`
CommunityMatches []*DiscoveryMatch `json:"community_matches"`
// ResourceMatches would be added via separate FindMatches call (hard match)
}
// GetProductsByOrganization gets products for a specific organization (efficient method)
func (s *Service) GetProductsByOrganization(ctx context.Context, organizationID string) ([]*domain.Product, error) {
if s.productRepo == nil {
return nil, fmt.Errorf("product repository not available")
}
return s.productRepo.GetByOrganization(ctx, organizationID)
}
// GetServicesByOrganization gets services for a specific organization (efficient method)
func (s *Service) GetServicesByOrganization(ctx context.Context, organizationID string) ([]*domain.Service, error) {
if s.serviceRepo == nil {
return nil, fmt.Errorf("service repository not available")
}
return s.serviceRepo.GetByOrganization(ctx, organizationID)
}
// CreateProduct creates a new product with site linking support
func (s *Service) CreateProduct(ctx context.Context, product *domain.Product) error {
if s.productRepo == nil {
return fmt.Errorf("product repository not available")
}
// If SiteID is provided, populate location from site
if product.SiteID != nil && *product.SiteID != "" {
site, err := s.siteRepo.GetByID(ctx, *product.SiteID)
if err == nil && site != nil {
// Set location from site coordinates
product.Location = domain.Point{
Latitude: site.Latitude,
Longitude: site.Longitude,
Valid: true,
}
}
}
if err := s.productRepo.Create(ctx, product); err != nil {
return err
}
// Sync to graph database if graph sync service is available
// Note: This would require passing graph sync service to matching service
// For now, we'll rely on event-driven sync
return nil
}
// CreateService creates a new service with site linking support
func (s *Service) CreateService(ctx context.Context, service *domain.Service) error {
if s.serviceRepo == nil {
return fmt.Errorf("service repository not available")
}
// If SiteID is provided, populate location from site
if service.SiteID != nil && *service.SiteID != "" {
site, err := s.siteRepo.GetByID(ctx, *service.SiteID)
if err == nil && site != nil {
// Set location from site coordinates
service.ServiceLocation = domain.Point{
Latitude: site.Latitude,
Longitude: site.Longitude,
Valid: true,
}
}
}
if err := s.serviceRepo.Create(ctx, service); err != nil {
return err
}
return nil
}
// CreateCommunityListing creates a new community listing
func (s *Service) CreateCommunityListing(ctx context.Context, listing *domain.CommunityListing) error {
if s.communityListingRepo == nil {
return fmt.Errorf("community listing repository not available")
}
// Validate the listing
if err := listing.Validate(); err != nil {
return fmt.Errorf("invalid listing: %w", err)
}
// Create the listing
if err := s.communityListingRepo.Create(ctx, listing); err != nil {
return fmt.Errorf("failed to create community listing: %w", err)
}
return nil
}

View File

@ -1,6 +1,7 @@
package middleware
import (
"fmt"
"net/http"
"strings"
@ -32,25 +33,74 @@ func AuthMiddleware(authService *service.AuthService) gin.HandlerFunc {
return
}
// Set user information in context
c.Set("user_id", user.ID)
c.Set("user_email", user.Email)
c.Set("user_role", string(user.Role))
// Debug: Log the role being set (only in development)
if gin.Mode() == gin.DebugMode {
fmt.Printf("[AuthMiddleware] Setting user_role=%s for user_id=%s\n", string(user.Role), user.ID)
}
c.Next()
}
}
func RequireRole(role string) gin.HandlerFunc {
return func(c *gin.Context) {
// Debug: Log all context keys (only in development)
if gin.Mode() == gin.DebugMode {
fmt.Printf("[RequireRole] Checking for role=%s, path=%s\n", role, c.Request.URL.Path)
// Try to get all context values for debugging
if userID, exists := c.Get("user_id"); exists {
fmt.Printf("[RequireRole] Found user_id=%v\n", userID)
}
if userEmail, exists := c.Get("user_email"); exists {
fmt.Printf("[RequireRole] Found user_email=%v\n", userEmail)
}
}
userRole, exists := c.Get("user_role")
if !exists {
c.JSON(http.StatusForbidden, gin.H{"error": "No role found"})
// Debug: List all available keys in context
if gin.Mode() == gin.DebugMode {
fmt.Printf("[RequireRole] user_role not found. Available keys in context:\n")
// Note: Gin doesn't provide a direct way to list all keys, but we can check common ones
}
c.JSON(http.StatusForbidden, gin.H{
"error": "No role found - authentication middleware may not be applied",
"required_role": role,
"path": c.Request.URL.Path,
})
c.Abort()
return
}
if userRole != role {
c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
// Convert to string for comparison - handle both string and interface{} types
var userRoleStr string
switch v := userRole.(type) {
case string:
userRoleStr = v
default:
userRoleStr = fmt.Sprintf("%v", v)
}
// Trim whitespace and compare (case-sensitive)
userRoleStr = strings.TrimSpace(userRoleStr)
if userRoleStr != role {
c.JSON(http.StatusForbidden, gin.H{
"error": "Insufficient permissions",
"required_role": role,
"user_role": userRoleStr,
"debug": map[string]interface{}{
"user_role_type": fmt.Sprintf("%T", userRole),
"user_role_raw": userRole,
"user_role_string": userRoleStr,
"required_role": role,
"match": userRoleStr == role,
},
})
c.Abort()
return
}

View File

@ -34,6 +34,38 @@ func (m *MockUserRepository) Create(ctx context.Context, user *domain.User) erro
return errors.New("not implemented")
}
func (m *MockUserRepository) Activate(ctx context.Context, userID string) error {
return errors.New("not implemented")
}
func (m *MockUserRepository) Update(ctx context.Context, user *domain.User) error {
return errors.New("not implemented")
}
func (m *MockUserRepository) Delete(ctx context.Context, id string) error {
return errors.New("not implemented")
}
func (m *MockUserRepository) List(ctx context.Context, filters domain.UserListFilters, pagination domain.PaginationParams) (*domain.PaginatedResult[domain.User], error) {
return nil, errors.New("not implemented")
}
func (m *MockUserRepository) UpdateRole(ctx context.Context, userID string, role domain.UserRole) error {
return errors.New("not implemented")
}
func (m *MockUserRepository) UpdatePermissions(ctx context.Context, userID string, permissions []string) error {
return errors.New("not implemented")
}
func (m *MockUserRepository) Deactivate(ctx context.Context, userID string) error {
return errors.New("not implemented")
}
func (m *MockUserRepository) UpdateLastLogin(ctx context.Context, userID string) error {
return errors.New("not implemented")
}
func TestAuthMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
@ -122,6 +154,7 @@ func TestRequireRole(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
// No user_role set
middleware(c)

View File

@ -0,0 +1,62 @@
package middleware
import (
"bugulma/backend/internal/service"
"strings"
"github.com/gin-gonic/gin"
)
// LocaleKey is the context key for locale
const LocaleKey = "locale"
// I18nMiddleware extracts locale from Accept-Language header or query parameter
// Sets locale in context for use by handlers
func I18nMiddleware(i18nService *service.I18nService) gin.HandlerFunc {
return func(c *gin.Context) {
locale := service.DefaultLocale
// Check query parameter first (highest priority)
if queryLocale := c.Query("locale"); queryLocale != "" {
if i18nService.ValidateLocale(queryLocale) {
locale = queryLocale
}
} else if headerLocale := c.GetHeader("Accept-Language"); headerLocale != "" {
// Parse Accept-Language header
locale = parseAcceptLanguage(headerLocale, i18nService)
}
// Set locale in context
c.Set(LocaleKey, locale)
c.Next()
}
}
// parseAcceptLanguage parses Accept-Language header and returns best match
func parseAcceptLanguage(header string, i18nService *service.I18nService) string {
// Parse header like "en-US,en;q=0.9,ru;q=0.8"
languages := strings.Split(header, ",")
for _, lang := range languages {
// Extract language code (e.g., "en" from "en-US" or "en;q=0.9")
parts := strings.Split(strings.TrimSpace(lang), ";")
langCode := strings.ToLower(strings.Split(parts[0], "-")[0])
if i18nService.ValidateLocale(langCode) {
return langCode
}
}
return service.DefaultLocale
}
// GetLocaleFromContext extracts locale from context
func GetLocaleFromContext(c *gin.Context) string {
if locale, exists := c.Get(LocaleKey); exists {
if loc, ok := locale.(string); ok {
return loc
}
}
return service.DefaultLocale
}

View File

@ -0,0 +1,117 @@
package repository
import (
"context"
"bugulma/backend/internal/domain"
"gorm.io/gorm"
)
// ActivityLogRepository implements domain.ActivityLogRepository with GORM
type ActivityLogRepository struct {
*BaseRepository[domain.ActivityLog]
db *gorm.DB
}
// NewActivityLogRepository creates a new GORM-based activity log repository
func NewActivityLogRepository(db *gorm.DB) domain.ActivityLogRepository {
return &ActivityLogRepository{
BaseRepository: NewBaseRepository[domain.ActivityLog](db),
db: db,
}
}
// GetByUser retrieves activity logs for a user with pagination
func (r *ActivityLogRepository) GetByUser(ctx context.Context, userID string, limit, offset int) ([]*domain.ActivityLog, int64, error) {
var activities []*domain.ActivityLog
var total int64
// Get total count
if err := r.db.WithContext(ctx).
Model(&domain.ActivityLog{}).
Where("user_id = ?", userID).
Count(&total).Error; err != nil {
return nil, 0, err
}
// Get paginated results
result := r.db.WithContext(ctx).
Where("user_id = ?", userID).
Order("timestamp DESC").
Limit(limit).
Offset(offset).
Find(&activities)
if result.Error != nil {
return nil, 0, result.Error
}
return activities, total, nil
}
// GetByTarget retrieves activity logs for a target entity with pagination
func (r *ActivityLogRepository) GetByTarget(ctx context.Context, targetType, targetID string, limit, offset int) ([]*domain.ActivityLog, int64, error) {
var activities []*domain.ActivityLog
var total int64
// Get total count
if err := r.db.WithContext(ctx).
Model(&domain.ActivityLog{}).
Where("target_type = ? AND target_id = ?", targetType, targetID).
Count(&total).Error; err != nil {
return nil, 0, err
}
// Get paginated results
result := r.db.WithContext(ctx).
Where("target_type = ? AND target_id = ?", targetType, targetID).
Order("timestamp DESC").
Limit(limit).
Offset(offset).
Find(&activities)
if result.Error != nil {
return nil, 0, result.Error
}
return activities, total, nil
}
// GetRecent retrieves recent activity logs
func (r *ActivityLogRepository) GetRecent(ctx context.Context, limit int) ([]*domain.ActivityLog, error) {
var activities []*domain.ActivityLog
result := r.db.WithContext(ctx).
Order("timestamp DESC").
Limit(limit).
Find(&activities)
if result.Error != nil {
return nil, result.Error
}
return activities, nil
}
// GetByAction retrieves activity logs by action type with pagination
func (r *ActivityLogRepository) GetByAction(ctx context.Context, action domain.ActivityAction, limit, offset int) ([]*domain.ActivityLog, int64, error) {
var activities []*domain.ActivityLog
var total int64
// Get total count
if err := r.db.WithContext(ctx).
Model(&domain.ActivityLog{}).
Where("action = ?", action).
Count(&total).Error; err != nil {
return nil, 0, err
}
// Get paginated results
result := r.db.WithContext(ctx).
Where("action = ?", action).
Order("timestamp DESC").
Limit(limit).
Offset(offset).
Find(&activities)
if result.Error != nil {
return nil, 0, result.Error
}
return activities, total, nil
}

View File

@ -5,6 +5,8 @@ import (
"bugulma/backend/internal/domain"
"bugulma/backend/internal/geospatial"
"gorm.io/gorm"
)
@ -51,20 +53,20 @@ func (r *AddressRepository) GetWithinRadius(ctx context.Context, lat, lng, radiu
if dialector == "postgres" {
// Use PostGIS for PostgreSQL
geo := geospatial.NewGeoHelper(r.DB())
query := `
SELECT * FROM addresses
WHERE latitude IS NOT NULL AND longitude IS NOT NULL
AND ST_DWithin(
ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)::geography,
ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography,
?
)
AND ` + geo.DWithinExpr("ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)") + `
ORDER BY ST_Distance(
ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)::geography,
ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography
` + geo.PointExpr() + `::geography
)
`
result := r.DB().WithContext(ctx).Raw(query, lng, lat, radiusKm*1000, lng, lat).Scan(&addresses)
// Use includeOrderBy=true so PointRadiusArgs returns args in the order
// [lng, lat, radiusKm, lng, lat] matching DWithin + ORDER BY placeholders
args := geo.PointRadiusArgs(lng, lat, radiusKm, true)
result := r.DB().WithContext(ctx).Raw(query, args...).Scan(&addresses)
if result.Error != nil {
return nil, result.Error
}

View File

@ -0,0 +1,126 @@
package repository
import (
"context"
"bugulma/backend/internal/domain"
"bugulma/backend/internal/geospatial"
"gorm.io/gorm"
)
// CommunityListingRepository implements domain.CommunityListingRepository with GORM
type CommunityListingRepository struct {
*BaseRepository[domain.CommunityListing]
}
// NewCommunityListingRepository creates a new GORM-based community listing repository
func NewCommunityListingRepository(db *gorm.DB) domain.CommunityListingRepository {
return &CommunityListingRepository{
BaseRepository: NewBaseRepository[domain.CommunityListing](db),
}
}
// GetByUser retrieves listings by user ID
func (r *CommunityListingRepository) GetByUser(ctx context.Context, userID string) ([]*domain.CommunityListing, error) {
return r.FindWhereWithContext(ctx, "user_id = ? AND status != ?", userID, domain.CommunityListingStatusArchived)
}
// GetByType retrieves listings by type
func (r *CommunityListingRepository) GetByType(ctx context.Context, listingType domain.CommunityListingType) ([]*domain.CommunityListing, error) {
return r.FindWhereWithContext(ctx, "listing_type = ? AND status = ?", listingType, domain.CommunityListingStatusActive)
}
// GetByCategory retrieves listings by category
func (r *CommunityListingRepository) GetByCategory(ctx context.Context, category string) ([]*domain.CommunityListing, error) {
return r.FindWhereWithContext(ctx, "category = ? AND status = ?", category, domain.CommunityListingStatusActive)
}
// SearchWithLocation performs spatial + text search for community listings
func (r *CommunityListingRepository) SearchWithLocation(ctx context.Context, query string, location *domain.Point, radiusKm float64) ([]*domain.CommunityListing, error) {
db := r.DB().WithContext(ctx)
// Build query with text search
q := db.Model(&domain.CommunityListing{}).
Where("status = ?", domain.CommunityListingStatusActive)
// Text search on title, description, and search_keywords
if query != "" {
q = q.Where(
"title ILIKE ? OR description ILIKE ? OR search_keywords ILIKE ?",
"%"+query+"%", "%"+query+"%", "%"+query+"%",
)
}
// Spatial search if location provided
if location != nil && location.Valid && radiusKm > 0 {
dialector := r.DB().Dialector.Name()
if dialector == "postgres" {
// Check if PostGIS is available
var postgisAvailable bool
var columnExists bool
if err := r.DB().Raw("SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'postgis')").Scan(&postgisAvailable).Error; err == nil && postgisAvailable {
if err := r.DB().Raw(`
SELECT EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_name = 'community_listings' AND column_name = 'location'
)
`).Scan(&columnExists).Error; err == nil && columnExists {
// Use PostGIS spatial query via GeoHelper
geo := geospatial.NewGeoHelper(r.DB())
q = q.Where(
"location IS NOT NULL AND "+geo.DWithinExpr("location"),
geo.PointRadiusArgs(location.Longitude, location.Latitude, radiusKm, false)...,
)
}
}
}
}
var listings []*domain.CommunityListing
if err := q.Find(&listings).Error; err != nil {
return nil, err
}
return listings, nil
}
// GetNearby retrieves community listings within a geographic radius
func (r *CommunityListingRepository) GetNearby(ctx context.Context, lat, lng, radiusKm float64) ([]*domain.CommunityListing, error) {
dialector := r.DB().Dialector.Name()
if dialector == "postgres" {
// Check if PostGIS is available
var postgisAvailable bool
var columnExists bool
if err := r.DB().Raw("SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'postgis')").Scan(&postgisAvailable).Error; err == nil && postgisAvailable {
if err := r.DB().Raw(`
SELECT EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_name = 'community_listings' AND column_name = 'location'
)
`).Scan(&columnExists).Error; err == nil && columnExists {
// Use PostGIS for spatial query
var listings []*domain.CommunityListing
geo := geospatial.NewGeoHelper(r.DB())
query := `
SELECT * FROM community_listings
WHERE location IS NOT NULL
AND status = 'active'
AND ` + geo.DWithinExpr("location") + `
ORDER BY ` + geo.OrderByDistanceExpr("location") + `
`
args := geo.PointRadiusArgs(lng, lat, radiusKm, true)
result := r.DB().WithContext(ctx).Raw(query, args...).Scan(&listings)
if result.Error != nil {
return nil, result.Error
}
return listings, nil
}
}
}
// Fallback: return active listings only (spatial filtering not available)
return r.FindWhereWithContext(ctx, "status = ?", domain.CommunityListingStatusActive)
}

View File

@ -0,0 +1,150 @@
package repository
import (
"context"
"strings"
"time"
"bugulma/backend/internal/domain"
"gorm.io/gorm"
)
// StaticPageRepository implements domain.StaticPageRepository with GORM
type StaticPageRepository struct {
*BaseRepository[domain.StaticPage]
db *gorm.DB
}
// NewStaticPageRepository creates a new GORM-based static page repository
func NewStaticPageRepository(db *gorm.DB) domain.StaticPageRepository {
return &StaticPageRepository{
BaseRepository: NewBaseRepository[domain.StaticPage](db),
db: db,
}
}
// GetBySlug retrieves a page by slug
func (r *StaticPageRepository) GetBySlug(ctx context.Context, slug string) (*domain.StaticPage, error) {
return r.FindOneWhereWithContext(ctx, "slug = ?", slug)
}
// Search searches pages by title and content
func (r *StaticPageRepository) Search(ctx context.Context, query string) ([]*domain.StaticPage, error) {
searchTerm := "%" + strings.ToLower(query) + "%"
return r.FindWhereWithContext(ctx, "LOWER(title) LIKE ? OR LOWER(content) LIKE ?", searchTerm, searchTerm)
}
// AnnouncementRepository implements domain.AnnouncementRepository with GORM
type AnnouncementRepository struct {
*BaseRepository[domain.Announcement]
db *gorm.DB
}
// NewAnnouncementRepository creates a new GORM-based announcement repository
func NewAnnouncementRepository(db *gorm.DB) domain.AnnouncementRepository {
return &AnnouncementRepository{
BaseRepository: NewBaseRepository[domain.Announcement](db),
db: db,
}
}
// GetAll retrieves announcements with filters
func (r *AnnouncementRepository) GetAll(ctx context.Context, filters domain.AnnouncementFilters) ([]*domain.Announcement, error) {
query := r.db.WithContext(ctx).Model(&domain.Announcement{})
if filters.IsActive != nil {
query = query.Where("is_active = ?", *filters.IsActive)
}
if filters.Priority != nil {
query = query.Where("priority = ?", *filters.Priority)
}
if filters.StartDate != nil {
query = query.Where("start_date >= ?", *filters.StartDate)
}
if filters.EndDate != nil {
query = query.Where("end_date <= ?", *filters.EndDate)
}
var announcements []*domain.Announcement
result := query.Order("created_at DESC").Find(&announcements)
if result.Error != nil {
return nil, result.Error
}
return announcements, nil
}
// GetActive retrieves active announcements
func (r *AnnouncementRepository) GetActive(ctx context.Context) ([]*domain.Announcement, error) {
now := time.Now()
return r.FindWhereWithContext(ctx, "is_active = ? AND (start_date IS NULL OR start_date <= ?) AND (end_date IS NULL OR end_date >= ?)", true, now, now)
}
// RecordView records a view for an announcement
func (r *AnnouncementRepository) RecordView(ctx context.Context, id string) error {
result := r.db.WithContext(ctx).
Model(&domain.Announcement{}).
Where("id = ?", id).
UpdateColumn("views", gorm.Expr("views + 1"))
return result.Error
}
// RecordClick records a click for an announcement
func (r *AnnouncementRepository) RecordClick(ctx context.Context, id string) error {
result := r.db.WithContext(ctx).
Model(&domain.Announcement{}).
Where("id = ?", id).
UpdateColumn("clicks", gorm.Expr("clicks + 1"))
return result.Error
}
// RecordDismissal records a dismissal for an announcement
func (r *AnnouncementRepository) RecordDismissal(ctx context.Context, id string) error {
result := r.db.WithContext(ctx).
Model(&domain.Announcement{}).
Where("id = ?", id).
UpdateColumn("dismissals", gorm.Expr("dismissals + 1"))
return result.Error
}
// MediaAssetRepository implements domain.MediaAssetRepository with GORM
type MediaAssetRepository struct {
*BaseRepository[domain.MediaAsset]
db *gorm.DB
}
// NewMediaAssetRepository creates a new GORM-based media asset repository
func NewMediaAssetRepository(db *gorm.DB) domain.MediaAssetRepository {
return &MediaAssetRepository{
BaseRepository: NewBaseRepository[domain.MediaAsset](db),
db: db,
}
}
// GetAll retrieves media assets with filters
func (r *MediaAssetRepository) GetAll(ctx context.Context, filters domain.MediaAssetFilters) ([]*domain.MediaAsset, error) {
query := r.db.WithContext(ctx).Model(&domain.MediaAsset{})
if filters.Type != nil {
query = query.Where("type = ?", *filters.Type)
}
if len(filters.Tags) > 0 {
// Search for assets containing any of the tags
for _, tag := range filters.Tags {
query = query.Where("tags::text LIKE ?", "%"+tag+"%")
}
}
var assets []*domain.MediaAsset
result := query.Order("created_at DESC").Find(&assets)
if result.Error != nil {
return nil, result.Error
}
return assets, nil
}
// Search searches media assets by filename and tags
func (r *MediaAssetRepository) Search(ctx context.Context, query string) ([]*domain.MediaAsset, error) {
searchTerm := "%" + strings.ToLower(query) + "%"
return r.FindWhereWithContext(ctx, "LOWER(filename) LIKE ? OR LOWER(original_name) LIKE ? OR tags::text LIKE ?", searchTerm, searchTerm, searchTerm)
}

View File

@ -0,0 +1,181 @@
package repository
import (
"context"
"fmt"
"gorm.io/gorm"
)
// EntityQueryBuilder provides a way to build queries for different entity types
type EntityQueryBuilder[T any] interface {
BuildFieldQuery(db *gorm.DB, field, value string) *gorm.DB
GetEntityType() string
}
// SiteQueryBuilder implements EntityQueryBuilder for Site entities
type SiteQueryBuilder struct{}
func (b *SiteQueryBuilder) BuildFieldQuery(db *gorm.DB, field, value string) *gorm.DB {
switch field {
case "name":
return db.Where("name = ?", value)
case "notes":
return db.Where("notes = ?", value)
case "builder_owner":
return db.Where("builder_owner = ?", value)
case "architect":
return db.Where("architect = ?", value)
case "original_purpose":
return db.Where("original_purpose = ?", value)
case "current_use":
return db.Where("current_use = ?", value)
case "style":
return db.Where("style = ?", value)
case "materials":
return db.Where("materials = ?", value)
default:
return db
}
}
func (b *SiteQueryBuilder) GetEntityType() string {
return "site"
}
// HeritageTitleQueryBuilder implements EntityQueryBuilder for HeritageTitle entities
type HeritageTitleQueryBuilder struct{}
func (b *HeritageTitleQueryBuilder) BuildFieldQuery(db *gorm.DB, field, value string) *gorm.DB {
switch field {
case "title":
return db.Where("title = ?", value)
case "content":
return db.Where("content = ?", value)
default:
return db
}
}
func (b *HeritageTitleQueryBuilder) GetEntityType() string {
return "heritage_title"
}
// HeritageTimelineItemQueryBuilder implements EntityQueryBuilder for HeritageTimelineItem entities
type HeritageTimelineItemQueryBuilder struct{}
func (b *HeritageTimelineItemQueryBuilder) BuildFieldQuery(db *gorm.DB, field, value string) *gorm.DB {
switch field {
case "title":
return db.Where("title = ?", value)
case "content":
return db.Where("content = ?", value)
default:
return db
}
}
func (b *HeritageTimelineItemQueryBuilder) GetEntityType() string {
return "heritage_timeline_item"
}
// HeritageSourceQueryBuilder implements EntityQueryBuilder for HeritageSource entities
type HeritageSourceQueryBuilder struct{}
func (b *HeritageSourceQueryBuilder) BuildFieldQuery(db *gorm.DB, field, value string) *gorm.DB {
switch field {
case "title":
return db.Where("title = ?", value)
default:
return db
}
}
func (b *HeritageSourceQueryBuilder) GetEntityType() string {
return "heritage_source"
}
// OrganizationQueryBuilder implements EntityQueryBuilder for Organization entities
type OrganizationQueryBuilder struct{}
func (b *OrganizationQueryBuilder) BuildFieldQuery(db *gorm.DB, field, value string) *gorm.DB {
switch field {
case "name":
return db.Where("name = ?", value)
case "description":
return db.Where("description = ?", value)
case "sector":
return db.Where("sector = ?", value)
case "sub_type":
return db.Where("subtype = ?", value)
default:
return db
}
}
func (b *OrganizationQueryBuilder) GetEntityType() string {
return "organization"
}
// GeographicalFeatureQueryBuilder implements EntityQueryBuilder for GeographicalFeature entities
type GeographicalFeatureQueryBuilder struct{}
func (b *GeographicalFeatureQueryBuilder) BuildFieldQuery(db *gorm.DB, field, value string) *gorm.DB {
switch field {
case "name":
return db.Where("name = ?", value)
case "properties":
return db.Where("properties::text LIKE ?", "%"+value+"%")
default:
return db
}
}
func (b *GeographicalFeatureQueryBuilder) GetEntityType() string {
return "geographical_feature"
}
// ProductQueryBuilder implements EntityQueryBuilder for Product entities
type ProductQueryBuilder struct{}
func (b *ProductQueryBuilder) BuildFieldQuery(db *gorm.DB, field, value string) *gorm.DB {
switch field {
case "name":
return db.Where("name = ?", value)
case "description":
return db.Where("description = ?", value)
default:
return db
}
}
func (b *ProductQueryBuilder) GetEntityType() string {
return "product"
}
// FindEntitiesByFieldValue is a generic function to find entities by field value
func FindEntitiesByFieldValue[T any](ctx context.Context, db *gorm.DB, builder EntityQueryBuilder[T], field, value string, limit int) ([]*T, error) {
query := builder.BuildFieldQuery(db.Model(new(T)), field, value)
if limit > 0 {
query = query.Limit(limit)
}
var entities []*T
err := query.WithContext(ctx).Find(&entities).Error
if err != nil {
return nil, fmt.Errorf("failed to find entities: %w", err)
}
return entities, nil
}
// GetEntityByID retrieves a single entity by ID
func GetEntityByID[T any](ctx context.Context, db *gorm.DB, id string) (*T, error) {
var entity T
err := db.WithContext(ctx).First(&entity, "id = ?", id).Error
if err != nil {
return nil, fmt.Errorf("failed to get entity: %w", err)
}
return &entity, nil
}

View File

@ -5,6 +5,7 @@ import (
"fmt"
"bugulma/backend/internal/domain"
"bugulma/backend/internal/geospatial"
"gorm.io/gorm"
)
@ -91,18 +92,16 @@ func (r *GeographicalFeatureRepository) BulkCreate(ctx context.Context, features
func (r *GeographicalFeatureRepository) GetFeaturesWithinRadius(ctx context.Context, featureType domain.GeographicalFeatureType, lat, lng, radiusKm float64) ([]*domain.GeographicalFeature, error) {
var features []*domain.GeographicalFeature
query := `
SELECT * FROM geographical_features
WHERE feature_type = ?
AND ST_DWithin(
geometry::geography,
ST_GeogFromText('POINT(? ?)'),
? * 1000
)
ORDER BY ST_Distance(geometry::geography, ST_GeogFromText('POINT(? ?)'))
`
geo := geospatial.NewGeoHelper(r.DB())
query := `SELECT * FROM geographical_features
WHERE feature_type = ? AND ` + geo.DWithinExpr("geometry") + `
ORDER BY ST_Distance(geometry::geography, ` + geo.PointExpr() + `::geography)`
result := r.DB().WithContext(ctx).Raw(query, featureType, lng, lat, radiusKm, lng, lat).Scan(&features)
// The SQL uses the parameterized point expression twice (within ST_DWithin and in ORDER BY)
// so we must include the repeated lng/lat args for the ORDER BY usage.
args := append([]interface{}{featureType}, geo.PointRadiusArgs(lng, lat, radiusKm, true)...)
// we want ST_DWithin args: lng, lat, radius (and no second point for ORDER BY in this specific query)
result := r.DB().WithContext(ctx).Raw(query, args...).Scan(&features)
if result.Error != nil {
return nil, result.Error
}

View File

@ -208,7 +208,7 @@ func (r *GraphOrganizationRepository) nodeToOrganization(node neo4j.Node) (*doma
org := &domain.Organization{
ID: props["id"].(string),
Name: getStringProp(props, "name"),
Sector: getStringProp(props, "sector"),
Sector: domain.OrganizationSector(getStringProp(props, "sector")),
Description: getStringProp(props, "description"),
LogoURL: getStringProp(props, "logo_url"),
Website: getStringProp(props, "website"),

View File

@ -36,6 +36,10 @@ func (r *GraphProductRepository) SyncToGraph(ctx context.Context, product *domai
specificationsJSON, _ := json.Marshal(product.Specifications)
sourcesJSON, _ := json.Marshal(product.Sources)
// Marshal new fields for discovery
tagsJSON, _ := json.Marshal(product.Tags)
imagesJSON, _ := json.Marshal(product.Images)
_, err := session.ExecuteWrite(ctx, func(tx neo4j.ManagedTransaction) (interface{}, error) {
cypher := `
MERGE (p:Product {id: $id})
@ -49,29 +53,49 @@ func (r *GraphProductRepository) SyncToGraph(ctx context.Context, product *domai
p.specifications = $specifications,
p.availability = $availability,
p.sources = $sources,
p.search_keywords = $search_keywords,
p.tags = $tags,
p.availability_status = $availability_status,
p.images = $images,
p.site_id = $site_id,
p.created_at = datetime($created_at),
p.updated_at = datetime($updated_at)
WITH p
MATCH (o:Organization {id: $organization_id})
MERGE (o)-[:SELLS]->(p)
WITH p, o
OPTIONAL MATCH (s:Site {id: $site_id})
FOREACH (x IN CASE WHEN s IS NOT NULL THEN [1] ELSE [] END |
MERGE (s)-[:HOSTS]->(p)
)
RETURN p.id
`
var siteID interface{}
if product.SiteID != nil {
siteID = *product.SiteID
}
params := map[string]interface{}{
"id": product.ID,
"name": product.Name,
"category": string(product.Category),
"description": product.Description,
"unit_price": product.UnitPrice,
"moq": product.MOQ,
"certifications": string(certificationsJSON),
"capacity": product.Capacity,
"specifications": string(specificationsJSON),
"availability": product.Availability,
"sources": string(sourcesJSON),
"created_at": product.CreatedAt.Format("2006-01-02T15:04:05Z"),
"updated_at": product.UpdatedAt.Format("2006-01-02T15:04:05Z"),
"organization_id": product.OrganizationID,
"id": product.ID,
"name": product.Name,
"category": string(product.Category),
"description": product.Description,
"unit_price": product.UnitPrice,
"moq": product.MOQ,
"certifications": string(certificationsJSON),
"capacity": product.Capacity,
"specifications": string(specificationsJSON),
"availability": product.Availability,
"sources": string(sourcesJSON),
"search_keywords": product.SearchKeywords,
"tags": string(tagsJSON),
"availability_status": product.AvailabilityStatus,
"images": string(imagesJSON),
"site_id": siteID,
"created_at": product.CreatedAt.Format("2006-01-02T15:04:05Z"),
"updated_at": product.UpdatedAt.Format("2006-01-02T15:04:05Z"),
"organization_id": product.OrganizationID,
}
result, err := tx.Run(ctx, cypher, params)

View File

@ -35,6 +35,8 @@ func (r *GraphServiceRepository) SyncToGraph(ctx context.Context, service *domai
certificationsJSON, _ := json.Marshal(service.Certifications)
specializationsJSON, _ := json.Marshal(service.Specializations)
sourcesJSON, _ := json.Marshal(service.Sources)
tagsJSON, _ := json.Marshal(service.Tags)
availabilityScheduleJSON, _ := json.Marshal(service.AvailabilitySchedule)
_, err := session.ExecuteWrite(ctx, func(tx neo4j.ManagedTransaction) (interface{}, error) {
cypher := `
@ -51,31 +53,56 @@ func (r *GraphServiceRepository) SyncToGraph(ctx context.Context, service *domai
s.specializations = $specializations,
s.availability = $availability,
s.sources = $sources,
s.search_keywords = $search_keywords,
s.tags = $tags,
s.availability_status = $availability_status,
s.availability_schedule = $availability_schedule,
s.site_id = $site_id,
s.created_at = datetime($created_at),
s.updated_at = datetime($updated_at)
WITH s
MATCH (o:Organization {id: $organization_id})
MERGE (o)-[:OFFERS]->(s)
WITH s, o
OPTIONAL MATCH (st:Site {id: $site_id})
FOREACH (x IN CASE WHEN st IS NOT NULL THEN [1] ELSE [] END |
MERGE (st)-[:HOSTS]->(s)
)
RETURN s.id
`
var siteID interface{}
if service.SiteID != nil {
siteID = *service.SiteID
}
var availabilitySchedule interface{}
if service.AvailabilitySchedule != nil {
availabilitySchedule = string(availabilityScheduleJSON)
}
params := map[string]interface{}{
"id": service.ID,
"type": string(service.Type),
"domain": service.Domain,
"description": service.Description,
"on_site": service.OnSite,
"hourly_rate": service.HourlyRate,
"service_area_km": service.ServiceAreaKm,
"certifications": string(certificationsJSON),
"response_time": service.ResponseTime,
"warranty": service.Warranty,
"specializations": string(specializationsJSON),
"availability": service.Availability,
"sources": string(sourcesJSON),
"created_at": service.CreatedAt.Format("2006-01-02T15:04:05Z"),
"updated_at": service.UpdatedAt.Format("2006-01-02T15:04:05Z"),
"organization_id": service.OrganizationID,
"id": service.ID,
"type": string(service.Type),
"domain": service.Domain,
"description": service.Description,
"on_site": service.OnSite,
"hourly_rate": service.HourlyRate,
"service_area_km": service.ServiceAreaKm,
"certifications": string(certificationsJSON),
"response_time": service.ResponseTime,
"warranty": service.Warranty,
"specializations": string(specializationsJSON),
"availability": service.Availability,
"sources": string(sourcesJSON),
"search_keywords": service.SearchKeywords,
"tags": string(tagsJSON),
"availability_status": service.AvailabilityStatus,
"availability_schedule": availabilitySchedule,
"site_id": siteID,
"created_at": service.CreatedAt.Format("2006-01-02T15:04:05Z"),
"updated_at": service.UpdatedAt.Format("2006-01-02T15:04:05Z"),
"organization_id": service.OrganizationID,
}
result, err := tx.Run(ctx, cypher, params)

View File

@ -2,6 +2,7 @@ package repository
import (
"bugulma/backend/internal/domain"
"context"
"fmt"
"gorm.io/gorm"
@ -9,12 +10,16 @@ import (
// HeritageRepository handles database operations for heritage data
type HeritageRepository struct {
db *gorm.DB
db *gorm.DB
timelineRepo *TimelineRepository
}
// NewHeritageRepository creates a new heritage repository
func NewHeritageRepository(db *gorm.DB) *HeritageRepository {
return &HeritageRepository{db: db}
return &HeritageRepository{
db: db,
timelineRepo: NewTimelineRepository(db),
}
}
// getLocalizedValue retrieves a localized value from the localizations table
@ -24,13 +29,12 @@ func (r *HeritageRepository) getLocalizedValue(entityType, entityID, field, loca
return "" // Russian is stored in main table, not in localizations
}
var localization domain.Localization
err := r.db.Where("entity_type = ? AND entity_id = ? AND field = ? AND locale = ?",
entityType, entityID, field, locale).First(&localization).Error
if err != nil {
locRepo := NewLocalizationRepository(r.db)
loc, err := locRepo.GetByEntityAndField(context.Background(), entityType, entityID, field, locale)
if err != nil || loc == nil {
return "" // Not found, will fallback to Russian
}
return localization.Value
return loc.Value
}
// GetAll retrieves all heritage data (title, timeline items, and sources) with localization support
@ -45,12 +49,15 @@ func (r *HeritageRepository) GetAll(locale string) (*domain.HeritageData, error)
var data domain.HeritageData
// Get title
// Get title (optional - don't fail if not found)
if err := r.db.First(&data.Title).Error; err != nil {
return nil, err
if err != gorm.ErrRecordNotFound {
return nil, err
}
// No title found - that's OK, leave as nil
}
// Apply localization to title if not Russian
// Apply localization to title if not Russian and title exists
if locale != "ru" && data.Title != nil {
entityID := fmt.Sprintf("%d", data.Title.ID)
if localizedTitle := r.getLocalizedValue("heritage_title", entityID, "title", locale); localizedTitle != "" {
@ -61,22 +68,16 @@ func (r *HeritageRepository) GetAll(locale string) (*domain.HeritageData, error)
}
}
// Get timeline items ordered by their order field
if err := r.db.Order(`"order" ASC`).Find(&data.TimelineItems).Error; err != nil {
// Get timeline items ordered by their order field (only heritage items)
var err error
data.TimelineItems, err = r.timelineRepo.GetHeritageItems(locale)
if err != nil {
return nil, err
}
// Apply localization to timeline items if not Russian
if locale != "ru" {
for i := range data.TimelineItems {
item := &data.TimelineItems[i]
if localizedTitle := r.getLocalizedValue("heritage_timeline_item", item.ID, "title", locale); localizedTitle != "" {
item.Title = localizedTitle
}
if localizedContent := r.getLocalizedValue("heritage_timeline_item", item.ID, "content", locale); localizedContent != "" {
item.Content = localizedContent
}
}
// Ensure we return an empty JSON array instead of `null` when there are no items
if data.TimelineItems == nil {
data.TimelineItems = []domain.TimelineItem{}
}
// Get sources ordered by their order field
@ -84,6 +85,11 @@ func (r *HeritageRepository) GetAll(locale string) (*domain.HeritageData, error)
return nil, err
}
// Ensure we return an empty JSON array instead of `null` when there are no sources
if data.Sources == nil {
data.Sources = []domain.HeritageSource{}
}
// Apply localization to sources if not Russian
if locale != "ru" {
for i := range data.Sources {
@ -108,12 +114,8 @@ func (r *HeritageRepository) GetTitle() (*domain.HeritageTitle, error) {
}
// GetTimelineItems retrieves all timeline items
func (r *HeritageRepository) GetTimelineItems() ([]domain.HeritageTimelineItem, error) {
var items []domain.HeritageTimelineItem
if err := r.db.Order(`"order" ASC`).Find(&items).Error; err != nil {
return nil, err
}
return items, nil
func (r *HeritageRepository) GetTimelineItems() ([]domain.TimelineItem, error) {
return r.timelineRepo.GetAll("ru", false) // Get all timeline items, default locale
}
// GetSources retrieves all sources
@ -141,8 +143,8 @@ func (r *HeritageRepository) CreateOrUpdateTitle(title *domain.HeritageTitle) er
}
// CreateTimelineItem creates a new timeline item
func (r *HeritageRepository) CreateTimelineItem(item *domain.HeritageTimelineItem) error {
return r.db.Create(item).Error
func (r *HeritageRepository) CreateTimelineItem(item *domain.TimelineItem) error {
return r.timelineRepo.Create(context.Background(), item)
}
// CreateSource creates a new source

View File

@ -0,0 +1,397 @@
package repository
import (
"bugulma/backend/internal/domain"
"bugulma/backend/internal/localization"
"context"
"fmt"
"strings"
"gorm.io/gorm"
)
// LocalizationRepository implements the domain.LocalizationRepository interface
type LocalizationRepository struct {
db *gorm.DB
}
// NewLocalizationRepository creates a new localization repository
func NewLocalizationRepository(db *gorm.DB) domain.LocalizationRepository {
return &LocalizationRepository{db: db}
}
// Create creates a new localization record
func (r *LocalizationRepository) Create(ctx context.Context, loc *domain.Localization) error {
if loc == nil {
return fmt.Errorf("localization cannot be nil")
}
// Validate required fields
if loc.EntityType == "" {
return fmt.Errorf("entity_type is required")
}
if loc.EntityID == "" {
return fmt.Errorf("entity_id is required")
}
if loc.Field == "" {
return fmt.Errorf("field is required")
}
if loc.Locale == "" {
return fmt.Errorf("locale is required")
}
if loc.Value == "" {
return fmt.Errorf("value cannot be empty")
}
// Generate composite ID if not provided
if loc.ID == "" {
loc.ID = fmt.Sprintf("%s_%s_%s_%s", loc.EntityType, loc.EntityID, loc.Field, loc.Locale)
}
return r.db.WithContext(ctx).Create(loc).Error
}
// GetByEntityAndField retrieves a localization by entity type, entity ID, field, and locale
func (r *LocalizationRepository) GetByEntityAndField(ctx context.Context, entityType, entityID, field, locale string) (*domain.Localization, error) {
if entityType == "" || entityID == "" || field == "" || locale == "" {
return nil, fmt.Errorf("all parameters (entityType, entityID, field, locale) are required")
}
var loc domain.Localization
err := r.db.WithContext(ctx).Where("entity_type = ? AND entity_id = ? AND field = ? AND locale = ?",
entityType, entityID, field, locale).First(&loc).Error
if err == gorm.ErrRecordNotFound {
return nil, nil // Return nil instead of error for not found
}
return &loc, err
}
// GetAllByEntity retrieves all localizations for a specific entity
func (r *LocalizationRepository) GetAllByEntity(ctx context.Context, entityType, entityID string) ([]*domain.Localization, error) {
if entityType == "" || entityID == "" {
return nil, fmt.Errorf("entityType and entityID are required")
}
var localizations []*domain.Localization
err := r.db.WithContext(ctx).Where("entity_type = ? AND entity_id = ?",
entityType, entityID).Order("field, locale").Find(&localizations).Error
return localizations, err
}
// Update updates an existing localization record
func (r *LocalizationRepository) Update(ctx context.Context, loc *domain.Localization) error {
if loc == nil {
return fmt.Errorf("localization cannot be nil")
}
if loc.ID == "" {
return fmt.Errorf("localization ID is required for update")
}
// Validate required fields
if loc.EntityType == "" {
return fmt.Errorf("entity_type is required")
}
if loc.EntityID == "" {
return fmt.Errorf("entity_id is required")
}
if loc.Field == "" {
return fmt.Errorf("field is required")
}
if loc.Locale == "" {
return fmt.Errorf("locale is required")
}
return r.db.WithContext(ctx).Save(loc).Error
}
// Delete deletes a localization record by ID
func (r *LocalizationRepository) Delete(ctx context.Context, id string) error {
if id == "" {
return fmt.Errorf("localization ID is required")
}
return r.db.WithContext(ctx).Delete(&domain.Localization{}, "id = ?", id).Error
}
// GetByEntityTypeAndLocale retrieves all localizations for entities of a specific type and locale
func (r *LocalizationRepository) GetByEntityTypeAndLocale(ctx context.Context, entityType, locale string) ([]*domain.Localization, error) {
if entityType == "" || locale == "" {
return nil, fmt.Errorf("entityType and locale are required")
}
var localizations []*domain.Localization
err := r.db.WithContext(ctx).Where("entity_type = ? AND locale = ?",
entityType, locale).Order("entity_id, field").Find(&localizations).Error
return localizations, err
}
// GetAllLocales returns all available locales in the system
func (r *LocalizationRepository) GetAllLocales(ctx context.Context) ([]string, error) {
var locales []string
err := r.db.WithContext(ctx).Model(&domain.Localization{}).
Distinct("locale").
Pluck("locale", &locales).Error
return locales, err
}
// GetSupportedLocalesForEntity returns locales that have translations for a specific entity
func (r *LocalizationRepository) GetSupportedLocalesForEntity(ctx context.Context, entityType, entityID string) ([]string, error) {
if entityType == "" || entityID == "" {
return nil, fmt.Errorf("entityType and entityID are required")
}
var locales []string
err := r.db.WithContext(ctx).Model(&domain.Localization{}).
Where("entity_type = ? AND entity_id = ?", entityType, entityID).
Distinct("locale").
Pluck("locale", &locales).Error
return locales, err
}
// BulkCreate creates multiple localization records in a transaction
func (r *LocalizationRepository) BulkCreate(ctx context.Context, localizations []*domain.Localization) error {
if len(localizations) == 0 {
return nil
}
// Validate all localizations before starting transaction
for i, loc := range localizations {
if loc == nil {
return fmt.Errorf("localization at index %d cannot be nil", i)
}
if loc.EntityType == "" || loc.EntityID == "" || loc.Field == "" || loc.Locale == "" {
return fmt.Errorf("localization at index %d has missing required fields", i)
}
// Generate composite ID if not provided
if loc.ID == "" {
loc.ID = fmt.Sprintf("%s_%s_%s_%s", loc.EntityType, loc.EntityID, loc.Field, loc.Locale)
}
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, loc := range localizations {
if err := tx.Create(loc).Error; err != nil {
return err
}
}
return nil
})
}
// BulkDelete deletes multiple localization records by their IDs in a transaction
func (r *LocalizationRepository) BulkDelete(ctx context.Context, ids []string) error {
if len(ids) == 0 {
return nil
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return tx.Where("id IN ?", ids).Delete(&domain.Localization{}).Error
})
}
// SearchLocalizations searches localizations by value content
func (r *LocalizationRepository) SearchLocalizations(ctx context.Context, query string, locale string, limit int) ([]*domain.Localization, error) {
if query == "" {
return nil, fmt.Errorf("search query cannot be empty")
}
var localizations []*domain.Localization
db := r.db.WithContext(ctx)
if locale != "" {
db = db.Where("locale = ?", locale)
}
err := db.Where("value ILIKE ?", "%"+query+"%").
Order("entity_type, entity_id, field").
Limit(limit).
Find(&localizations).Error
return localizations, err
}
// GetTranslationCountsByEntity returns translation counts grouped by entity type and locale
func (r *LocalizationRepository) GetTranslationCountsByEntity(ctx context.Context) (map[string]map[string]int, error) {
var results []struct {
EntityType string
Locale string
Count int
}
err := r.db.WithContext(ctx).Model(&domain.Localization{}).
Select("entity_type, locale, COUNT(*) as count").
Group("entity_type, locale").
Order("entity_type, locale").
Scan(&results).Error
if err != nil {
return nil, err
}
counts := make(map[string]map[string]int)
for _, result := range results {
if counts[result.EntityType] == nil {
counts[result.EntityType] = make(map[string]int)
}
counts[result.EntityType][result.Locale] = result.Count
}
return counts, nil
}
// GetUntranslatedFields returns fields that exist in entities but don't have translations
func (r *LocalizationRepository) GetUntranslatedFields(ctx context.Context, entityType, targetLocale string, entityIDs []string) ([]UntranslatedFieldInfo, error) {
// This would require joining with the actual entity tables
// For now, return empty - this is a complex query that would need to be implemented
// based on the specific entity schema
return []UntranslatedFieldInfo{}, nil
}
// UntranslatedFieldInfo represents information about a field that needs translation
type UntranslatedFieldInfo struct {
EntityID string
Field string
RussianValue string
}
// GetTranslationReuseCandidates finds Russian text that appears in multiple entities
// This can help identify good candidates for caching
func (r *LocalizationRepository) GetTranslationReuseCandidates(ctx context.Context, entityType, field, locale string) ([]domain.ReuseCandidate, error) {
var results []struct {
RussianValue string
Count int
}
// Find Russian values that appear in multiple entities of the same type
err := r.db.WithContext(ctx).Model(&domain.Localization{}).
Select("value as russian_value, COUNT(DISTINCT entity_id) as count").
Where("entity_type = ? AND field = ? AND locale = 'ru'", entityType, field).
Group("value").
Having("COUNT(DISTINCT entity_id) > 1").
Order("count DESC").
Limit(50).
Scan(&results).Error
if err != nil {
return nil, err
}
candidates := make([]domain.ReuseCandidate, len(results))
for i, result := range results {
candidates[i] = domain.ReuseCandidate{
RussianValue: result.RussianValue,
EntityCount: result.Count,
}
}
return candidates, nil
}
// GetEntitiesNeedingTranslation returns entity IDs that need translation for specific fields
// It queries entity tables to find entities with Russian content that don't have translations
func (r *LocalizationRepository) GetEntitiesNeedingTranslation(ctx context.Context, entityType, field, targetLocale string, limit int) ([]string, error) {
if entityType == "" || field == "" || targetLocale == "" {
return nil, fmt.Errorf("entityType, field, and targetLocale are required")
}
if limit <= 0 {
limit = 100
}
if limit > 1000 {
limit = 1000
}
// Get entity descriptor from registry to know table name
desc, exists := localization.GetEntityDescriptor(entityType)
if !exists {
return nil, fmt.Errorf("unknown entity type: %s", entityType)
}
// Build query to find entities that:
// 1. Have non-empty Russian content in the specified field
// 2. Don't have a translation in the target locale
// This uses a LEFT JOIN to find missing translations
// We need to use GORM's Raw with proper parameterization
// The field name needs to be validated against the entity descriptor
fieldValid := false
for _, f := range desc.Fields {
if f == field {
fieldValid = true
break
}
}
if !fieldValid {
return nil, fmt.Errorf("field '%s' is not valid for entity type '%s'", field, entityType)
}
// Use parameterized query with proper escaping for table/column names
// Note: Table and column names cannot be parameterized, so we validate them above
// GORM uses ? placeholders for parameters
query := fmt.Sprintf(`
SELECT DISTINCT e.%s as entity_id
FROM %s e
LEFT JOIN localizations l
ON l.entity_type = ?
AND l.entity_id = e.%s
AND l.field = ?
AND l.locale = ?
WHERE e.%s IS NOT NULL
AND e.%s != ''
AND l.id IS NULL
LIMIT ?
`, desc.IDField, desc.TableName, desc.IDField, field, field)
var entityIDs []string
err := r.db.WithContext(ctx).Raw(query, entityType, field, targetLocale, limit).Pluck("entity_id", &entityIDs).Error
if err != nil {
return nil, fmt.Errorf("failed to query entities needing translation: %w", err)
}
return entityIDs, nil
}
// FindExistingTranslationByRussianText finds an existing translation by matching Russian source text
// It looks for entities with the same Russian text in the same field that already have a translation
// Returns the translation value if found, empty string if not found
func (r *LocalizationRepository) FindExistingTranslationByRussianText(ctx context.Context, entityType, field, targetLocale, russianText string) (string, error) {
if entityType == "" || field == "" || targetLocale == "" || russianText == "" {
return "", nil
}
// Normalize the Russian text for matching
normalized := strings.TrimSpace(russianText)
if normalized == "" {
return "", nil
}
// Query: Find a localization where:
// 1. The entity has a Russian localization with matching text
// 2. The same entity has a translation in the target locale
var translation string
err := r.db.WithContext(ctx).Raw(`
SELECT l_target.value
FROM localizations l_ru
INNER JOIN localizations l_target
ON l_ru.entity_type = l_target.entity_type
AND l_ru.entity_id = l_target.entity_id
AND l_ru.field = l_target.field
WHERE l_ru.entity_type = ?
AND l_ru.field = ?
AND l_ru.locale = 'ru'
AND l_ru.value = ?
AND l_target.locale = ?
LIMIT 1
`, entityType, field, normalized, targetLocale).Scan(&translation).Error
if err != nil {
return "", fmt.Errorf("failed to find existing translation: %w", err)
}
return translation, nil
}

View File

@ -4,6 +4,7 @@ import (
"context"
"bugulma/backend/internal/domain"
"bugulma/backend/internal/geospatial"
"gorm.io/gorm"
)
@ -21,7 +22,7 @@ func NewOrganizationRepository(db *gorm.DB) domain.OrganizationRepository {
}
// GetBySector retrieves organizations by sector (NACE code or category)
func (r *OrganizationRepository) GetBySector(ctx context.Context, sector string) ([]*domain.Organization, error) {
func (r *OrganizationRepository) GetBySector(ctx context.Context, sector domain.OrganizationSector) ([]*domain.Organization, error) {
return r.FindWhereWithContext(ctx, "sector = ?", sector)
}
@ -38,17 +39,15 @@ func (r *OrganizationRepository) GetWithinRadius(ctx context.Context, lat, lng,
if dialector == "postgres" {
// Use PostGIS for PostgreSQL
var orgs []*domain.Organization
geo := geospatial.NewGeoHelper(r.DB())
query := `
SELECT * FROM organizations
WHERE location_geometry IS NOT NULL
AND ST_DWithin(
location_geometry::geography,
ST_GeogFromText('POINT(? ?)'),
? * 1000
)
ORDER BY location_geometry <-> ST_GeogFromText('POINT(? ?)')
AND ` + geo.DWithinExpr("location_geometry") + `
ORDER BY ` + geo.OrderByDistanceExpr("location_geometry") + `
`
result := r.DB().WithContext(ctx).Raw(query, lng, lat, radiusKm, lng, lat).Scan(&orgs)
args := geo.PointRadiusArgs(lng, lat, radiusKm, true)
result := r.DB().WithContext(ctx).Raw(query, args...).Scan(&orgs)
if result.Error != nil {
return nil, result.Error
}
@ -208,3 +207,17 @@ func (r *OrganizationRepository) GetSectorStats(ctx context.Context, limit int)
return stats, nil
}
// GetResourceFlowsByTypeAndDirection returns resource flows by type and direction
func (r *OrganizationRepository) GetResourceFlowsByTypeAndDirection(ctx context.Context, resourceType string, direction string) ([]*domain.ResourceFlow, error) {
var flows []*domain.ResourceFlow
result := r.DB().WithContext(ctx).
Where("type = ? AND direction = ?", resourceType, direction).
Find(&flows)
if result.Error != nil {
return nil, result.Error
}
return flows, nil
}

View File

@ -40,8 +40,8 @@ func (suite *OrganizationRepositoryTestSuite) TestCreate() {
org := &domain.Organization{
ID: "org-1",
Name: "Test Org",
Sector: "Manufacturing",
Subtype: domain.SubtypeCommercial,
Sector: domain.SectorManufacturing,
Subtype: domain.SubtypeFactory,
Latitude: 52.52,
Longitude: 13.405,
}
@ -72,19 +72,19 @@ func (suite *OrganizationRepositoryTestSuite) TestGetBySector() {
org1 := &domain.Organization{
ID: "org-1",
Name: "Org 1",
Sector: "Manufacturing",
Sector: domain.SectorManufacturing,
}
org2 := &domain.Organization{
ID: "org-2",
Name: "Org 2",
Sector: "Retail",
Sector: domain.SectorRetail,
}
err := suite.repo.Create(context.Background(), org1)
suite.Require().NoError(err)
err = suite.repo.Create(context.Background(), org2)
suite.Require().NoError(err)
orgs, err := suite.repo.GetBySector(context.Background(), "Manufacturing")
orgs, err := suite.repo.GetBySector(context.Background(), domain.SectorManufacturing)
assert.NoError(suite.T(), err)
assert.Len(suite.T(), orgs, 1)
assert.Equal(suite.T(), "Org 1", orgs[0].Name)
@ -94,7 +94,7 @@ func (suite *OrganizationRepositoryTestSuite) TestGetBySubtype() {
org1 := &domain.Organization{
ID: "org-1",
Name: "Org 1",
Subtype: domain.SubtypeCommercial,
Subtype: domain.SubtypeConsultant,
}
org2 := &domain.Organization{
ID: "org-2",
@ -106,7 +106,7 @@ func (suite *OrganizationRepositoryTestSuite) TestGetBySubtype() {
err = suite.repo.Create(context.Background(), org2)
suite.Require().NoError(err)
orgs, err := suite.repo.GetBySubtype(context.Background(), domain.SubtypeCommercial)
orgs, err := suite.repo.GetBySubtype(context.Background(), domain.SubtypeConsultant)
assert.NoError(suite.T(), err)
assert.Len(suite.T(), orgs, 1)
assert.Equal(suite.T(), "Org 1", orgs[0].Name)

View File

@ -5,6 +5,8 @@ import (
"bugulma/backend/internal/domain"
"bugulma/backend/internal/geospatial"
"gorm.io/gorm"
)
@ -39,3 +41,94 @@ func (r *ProductRepository) SearchByName(ctx context.Context, name string) ([]*d
func (r *ProductRepository) GetByPriceRange(ctx context.Context, minPrice, maxPrice float64) ([]*domain.Product, error) {
return r.FindWhereWithContext(ctx, "unit_price BETWEEN ? AND ?", minPrice, maxPrice)
}
// SearchWithLocation performs spatial + text search for products
func (r *ProductRepository) SearchWithLocation(ctx context.Context, query string, location *domain.Point, radiusKm float64) ([]*domain.Product, error) {
db := r.DB().WithContext(ctx)
// Build query with text search
q := db.Model(&domain.Product{})
// Text search on name, description, and search_keywords
if query != "" {
q = q.Where(
"name ILIKE ? OR description ILIKE ? OR search_keywords ILIKE ?",
"%"+query+"%", "%"+query+"%", "%"+query+"%",
)
}
// Spatial search if location provided
if location != nil && location.Valid && radiusKm > 0 {
dialector := r.DB().Dialector.Name()
if dialector == "postgres" {
// Check if PostGIS is available
var postgisAvailable bool
var columnExists bool
if err := r.DB().Raw("SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'postgis')").Scan(&postgisAvailable).Error; err == nil && postgisAvailable {
if err := r.DB().Raw(`
SELECT EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_name = 'products' AND column_name = 'location'
)
`).Scan(&columnExists).Error; err == nil && columnExists {
// Use PostGIS spatial query via GeoHelper
geo := geospatial.NewGeoHelper(r.DB())
q = q.Where(
"location IS NOT NULL AND "+geo.DWithinExpr("location"),
geo.PointRadiusArgs(location.Longitude, location.Latitude, radiusKm, false)...,
)
}
}
}
}
var products []*domain.Product
if err := q.Find(&products).Error; err != nil {
return nil, err
}
return products, nil
}
// GetBySite retrieves products at a specific site
func (r *ProductRepository) GetBySite(ctx context.Context, siteID string) ([]*domain.Product, error) {
return r.FindWhereWithContext(ctx, "site_id = ?", siteID)
}
// GetNearby retrieves products within a geographic radius
func (r *ProductRepository) GetNearby(ctx context.Context, lat, lng, radiusKm float64) ([]*domain.Product, error) {
dialector := r.DB().Dialector.Name()
if dialector == "postgres" {
// Check if PostGIS is available
var postgisAvailable bool
var columnExists bool
if err := r.DB().Raw("SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'postgis')").Scan(&postgisAvailable).Error; err == nil && postgisAvailable {
if err := r.DB().Raw(`
SELECT EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_name = 'products' AND column_name = 'location'
)
`).Scan(&columnExists).Error; err == nil && columnExists {
// Use PostGIS for spatial query
var products []*domain.Product
geo := geospatial.NewGeoHelper(r.DB())
query := `
SELECT * FROM products
WHERE location IS NOT NULL
AND ` + geo.DWithinExpr("location") + `
ORDER BY ` + geo.OrderByDistanceExpr("location") + `
`
args := geo.PointRadiusArgs(lng, lat, radiusKm, true)
result := r.DB().WithContext(ctx).Raw(query, args...).Scan(&products)
if result.Error != nil {
return nil, result.Error
}
return products, nil
}
}
}
// Fallback: return all products (spatial filtering not available)
return r.GetAll(ctx)
}

View File

@ -4,6 +4,7 @@ import (
"context"
"bugulma/backend/internal/domain"
"bugulma/backend/internal/geospatial"
"gorm.io/gorm"
)
@ -46,3 +47,93 @@ func (r *ServiceRepository) GetByServiceArea(ctx context.Context, lat, lng, radi
// For now, return services with service area >= requested radius
return r.FindWhereWithContext(ctx, "service_area_km >= ?", radiusKm)
}
// SearchWithLocation performs spatial + text search for services
func (r *ServiceRepository) SearchWithLocation(ctx context.Context, query string, location *domain.Point, radiusKm float64) ([]*domain.Service, error) {
db := r.DB().WithContext(ctx)
// Build query with text search
q := db.Model(&domain.Service{})
// Text search on domain, description, and search_keywords
if query != "" {
q = q.Where(
"domain ILIKE ? OR description ILIKE ? OR search_keywords ILIKE ?",
"%"+query+"%", "%"+query+"%", "%"+query+"%",
)
}
// Spatial search if location provided
if location != nil && location.Valid && radiusKm > 0 {
dialector := r.DB().Dialector.Name()
if dialector == "postgres" {
// Check if PostGIS is available
var postgisAvailable bool
var columnExists bool
if err := r.DB().Raw("SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'postgis')").Scan(&postgisAvailable).Error; err == nil && postgisAvailable {
if err := r.DB().Raw(`
SELECT EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_name = 'services' AND column_name = 'service_location'
)
`).Scan(&columnExists).Error; err == nil && columnExists {
// Use PostGIS spatial query via GeoHelper to ensure correct parameter ordering
geo := geospatial.NewGeoHelper(r.DB())
q = q.Where(
"service_location IS NOT NULL AND "+geo.DWithinExpr("service_location"),
geo.PointRadiusArgs(location.Longitude, location.Latitude, radiusKm, false)...,
)
}
}
}
}
var services []*domain.Service
if err := q.Find(&services).Error; err != nil {
return nil, err
}
return services, nil
}
// GetBySite retrieves services at a specific site
func (r *ServiceRepository) GetBySite(ctx context.Context, siteID string) ([]*domain.Service, error) {
return r.FindWhereWithContext(ctx, "site_id = ?", siteID)
}
// GetNearby retrieves services within a geographic radius
func (r *ServiceRepository) GetNearby(ctx context.Context, lat, lng, radiusKm float64) ([]*domain.Service, error) {
dialector := r.DB().Dialector.Name()
if dialector == "postgres" {
// Check if PostGIS is available
var postgisAvailable bool
var columnExists bool
if err := r.DB().Raw("SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'postgis')").Scan(&postgisAvailable).Error; err == nil && postgisAvailable {
if err := r.DB().Raw(`
SELECT EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_name = 'services' AND column_name = 'service_location'
)
`).Scan(&columnExists).Error; err == nil && columnExists {
// Use PostGIS for spatial query
var services []*domain.Service
query := `
SELECT * FROM services
WHERE service_location IS NOT NULL
AND ` + geospatial.NewGeoHelper(r.DB()).DWithinExpr("service_location") + `
ORDER BY ` + geospatial.NewGeoHelper(r.DB()).OrderByDistanceExpr("service_location") + `
`
args := geospatial.NewGeoHelper(r.DB()).PointRadiusArgs(lng, lat, radiusKm, true)
result := r.DB().WithContext(ctx).Raw(query, args...).Scan(&services)
if result.Error != nil {
return nil, result.Error
}
return services, nil
}
}
}
// Fallback: return all services (spatial filtering not available)
return r.GetAll(ctx)
}

View File

@ -4,6 +4,7 @@ import (
"context"
"bugulma/backend/internal/domain"
"bugulma/backend/internal/geospatial"
"gorm.io/gorm"
)
@ -48,17 +49,16 @@ func (r *SiteRepository) GetWithinRadius(ctx context.Context, lat, lng, radiusKm
if postgisAvailable && columnExists {
// Use PostGIS for PostgreSQL
var sites []*domain.Site
// use helper to build safe PostGIS snippets and args
geo := geospatial.NewGeoHelper(r.DB())
query := `
SELECT * FROM sites
WHERE location_geometry IS NOT NULL
AND ST_DWithin(
location_geometry::geography,
ST_GeogFromText('POINT(? ?)'),
? * 1000
)
ORDER BY location_geometry <-> ST_GeogFromText('POINT(? ?)')
AND ` + geo.DWithinExpr("location_geometry") + `
ORDER BY ` + geo.OrderByDistanceExpr("location_geometry") + `
`
result := r.DB().WithContext(ctx).Raw(query, lng, lat, radiusKm, lng, lat).Scan(&sites)
args := geo.PointRadiusArgs(lng, lat, radiusKm, true)
result := r.DB().WithContext(ctx).Raw(query, args...).Scan(&sites)
if result.Error != nil {
return nil, result.Error
}
@ -94,13 +94,12 @@ func (r *SiteRepository) getLocalizedValue(entityType, entityID, field, locale s
return "" // Russian is stored in main table, not in localizations
}
var localization domain.Localization
err := r.DB().Where("entity_type = ? AND entity_id = ? AND field = ? AND locale = ?",
entityType, entityID, field, locale).First(&localization).Error
if err != nil {
locRepo := NewLocalizationRepository(r.DB())
loc, err := locRepo.GetByEntityAndField(context.Background(), entityType, entityID, field, locale)
if err != nil || loc == nil {
return "" // Not found, will fallback to Russian
}
return localization.Value
return loc.Value
}
// GetHeritageSites retrieves sites that have heritage status with localization support

View File

@ -0,0 +1,265 @@
package repository
import (
"context"
"errors"
"time"
"bugulma/backend/internal/domain"
"gorm.io/gorm"
)
// SubscriptionRepository implements domain.SubscriptionRepository with GORM
type SubscriptionRepository struct {
*BaseRepository[domain.Subscription]
db *gorm.DB
}
// NewSubscriptionRepository creates a new GORM-based subscription repository
func NewSubscriptionRepository(db *gorm.DB) domain.SubscriptionRepository {
return &SubscriptionRepository{
BaseRepository: NewBaseRepository[domain.Subscription](db),
db: db,
}
}
// GetByUserID retrieves a subscription by user ID
func (r *SubscriptionRepository) GetByUserID(ctx context.Context, userID string) (*domain.Subscription, error) {
var subscription domain.Subscription
result := r.db.WithContext(ctx).Where("user_id = ?", userID).First(&subscription)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return nil, result.Error
}
return &subscription, nil
}
// GetActiveByUserID retrieves an active subscription by user ID
func (r *SubscriptionRepository) GetActiveByUserID(ctx context.Context, userID string) (*domain.Subscription, error) {
var subscription domain.Subscription
result := r.db.WithContext(ctx).
Where("user_id = ? AND status IN ?", userID, []domain.SubscriptionStatus{
domain.SubscriptionStatusActive,
domain.SubscriptionStatusTrialing,
}).
First(&subscription)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return nil, result.Error
}
return &subscription, nil
}
// UpdateStatus updates the status of a subscription
func (r *SubscriptionRepository) UpdateStatus(ctx context.Context, id string, status domain.SubscriptionStatus) error {
result := r.db.WithContext(ctx).
Model(&domain.Subscription{}).
Where("id = ?", id).
Update("status", status)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrNotFound
}
return nil
}
// PaymentMethodRepository implements domain.PaymentMethodRepository with GORM
type PaymentMethodRepository struct {
*BaseRepository[domain.PaymentMethod]
db *gorm.DB
}
// NewPaymentMethodRepository creates a new GORM-based payment method repository
func NewPaymentMethodRepository(db *gorm.DB) domain.PaymentMethodRepository {
return &PaymentMethodRepository{
BaseRepository: NewBaseRepository[domain.PaymentMethod](db),
db: db,
}
}
// GetByUserID retrieves all payment methods for a user
func (r *PaymentMethodRepository) GetByUserID(ctx context.Context, userID string) ([]*domain.PaymentMethod, error) {
var paymentMethods []*domain.PaymentMethod
result := r.db.WithContext(ctx).
Where("user_id = ?", userID).
Order("is_default DESC, created_at DESC").
Find(&paymentMethods)
if result.Error != nil {
return nil, result.Error
}
return paymentMethods, nil
}
// GetDefaultByUserID retrieves the default payment method for a user
func (r *PaymentMethodRepository) GetDefaultByUserID(ctx context.Context, userID string) (*domain.PaymentMethod, error) {
var paymentMethod domain.PaymentMethod
result := r.db.WithContext(ctx).
Where("user_id = ? AND is_default = ?", userID, true).
First(&paymentMethod)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return nil, result.Error
}
return &paymentMethod, nil
}
// SetDefault sets a payment method as default for a user
func (r *PaymentMethodRepository) SetDefault(ctx context.Context, userID string, paymentMethodID string) error {
// Start transaction
tx := r.db.WithContext(ctx).Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
// Unset all defaults for user
if err := tx.Model(&domain.PaymentMethod{}).
Where("user_id = ?", userID).
Update("is_default", false).Error; err != nil {
tx.Rollback()
return err
}
// Set new default
if err := tx.Model(&domain.PaymentMethod{}).
Where("id = ? AND user_id = ?", paymentMethodID, userID).
Update("is_default", true).Error; err != nil {
tx.Rollback()
return err
}
return tx.Commit().Error
}
// InvoiceRepository implements domain.InvoiceRepository with GORM
type InvoiceRepository struct {
*BaseRepository[domain.Invoice]
db *gorm.DB
}
// NewInvoiceRepository creates a new GORM-based invoice repository
func NewInvoiceRepository(db *gorm.DB) domain.InvoiceRepository {
return &InvoiceRepository{
BaseRepository: NewBaseRepository[domain.Invoice](db),
db: db,
}
}
// GetByUserID retrieves invoices for a user with pagination
func (r *InvoiceRepository) GetByUserID(ctx context.Context, userID string, limit, offset int) ([]*domain.Invoice, int64, error) {
var invoices []*domain.Invoice
var total int64
// Get total count
if err := r.db.WithContext(ctx).
Model(&domain.Invoice{}).
Where("user_id = ?", userID).
Count(&total).Error; err != nil {
return nil, 0, err
}
// Get paginated results
result := r.db.WithContext(ctx).
Where("user_id = ?", userID).
Order("created_at DESC").
Limit(limit).
Offset(offset).
Find(&invoices)
if result.Error != nil {
return nil, 0, result.Error
}
return invoices, total, nil
}
// GetBySubscriptionID retrieves all invoices for a subscription
func (r *InvoiceRepository) GetBySubscriptionID(ctx context.Context, subscriptionID string) ([]*domain.Invoice, error) {
var invoices []*domain.Invoice
result := r.db.WithContext(ctx).
Where("subscription_id = ?", subscriptionID).
Order("created_at DESC").
Find(&invoices)
if result.Error != nil {
return nil, result.Error
}
return invoices, nil
}
// UsageTrackingRepository implements domain.UsageTrackingRepository with GORM
type UsageTrackingRepository struct {
*BaseRepository[domain.UsageTracking]
db *gorm.DB
}
// NewUsageTrackingRepository creates a new GORM-based usage tracking repository
func NewUsageTrackingRepository(db *gorm.DB) domain.UsageTrackingRepository {
return &UsageTrackingRepository{
BaseRepository: NewBaseRepository[domain.UsageTracking](db),
db: db,
}
}
// GetByUserIDAndType retrieves usage tracking for a user and limit type in a specific period
func (r *UsageTrackingRepository) GetByUserIDAndType(ctx context.Context, userID string, limitType domain.UsageLimitType, periodStart time.Time) (*domain.UsageTracking, error) {
var usage domain.UsageTracking
result := r.db.WithContext(ctx).
Where("user_id = ? AND limit_type = ? AND period_start = ?", userID, limitType, periodStart).
First(&usage)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return nil, result.Error
}
return &usage, nil
}
// UpdateUsage updates or creates usage tracking
func (r *UsageTrackingRepository) UpdateUsage(ctx context.Context, userID string, limitType domain.UsageLimitType, periodStart time.Time, amount int64) error {
// Try to get existing record
usage, err := r.GetByUserIDAndType(ctx, userID, limitType, periodStart)
if err != nil && !errors.Is(err, ErrNotFound) {
return err
}
if usage != nil {
// Update existing
usage.CurrentUsage = amount
return r.db.WithContext(ctx).Save(usage).Error
}
// Create new
periodEnd := periodStart.AddDate(0, 1, 0) // Default to monthly period
if limitType == domain.UsageLimitTypeAPICalls {
// API calls reset monthly
periodEnd = periodStart.AddDate(0, 1, 0)
}
newUsage := &domain.UsageTracking{
UserID: userID,
LimitType: limitType,
CurrentUsage: amount,
PeriodStart: periodStart,
PeriodEnd: periodEnd,
}
return r.db.WithContext(ctx).Create(newUsage).Error
}
// GetCurrentPeriodUsage retrieves current period usage
func (r *UsageTrackingRepository) GetCurrentPeriodUsage(ctx context.Context, userID string, limitType domain.UsageLimitType) (*domain.UsageTracking, error) {
// Get current month start
now := time.Now()
periodStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
return r.GetByUserIDAndType(ctx, userID, limitType, periodStart)
}

View File

@ -0,0 +1,117 @@
package repository
import (
"context"
"database/sql"
"bugulma/backend/internal/domain"
"gorm.io/gorm"
)
// TimelineRepository implements domain.TimelineRepository with GORM
type TimelineRepository struct {
*BaseRepository[domain.TimelineItem]
}
// NewTimelineRepository creates a new GORM-based timeline repository
func NewTimelineRepository(db *gorm.DB) *TimelineRepository {
return &TimelineRepository{
BaseRepository: NewBaseRepository[domain.TimelineItem](db),
}
}
// getLocalizedValue retrieves a localized value from the localizations table
// Returns empty string if not found (will fallback to default)
func (r *TimelineRepository) getLocalizedValue(entityType, entityID, field, locale string) string {
if locale == "en" || locale == "tt" { // Only localize if not default (assuming default is Russian)
locRepo := NewLocalizationRepository(r.DB())
loc, err := locRepo.GetByEntityAndField(context.Background(), entityType, entityID, field, locale)
if err != nil || loc == nil {
return "" // Not found, will fallback to default
}
return loc.Value
}
return "" // Default locale, no localization needed
}
// GetAll retrieves all timeline items with optional filtering
func (r *TimelineRepository) GetAll(locale string, heritageOnly bool) ([]domain.TimelineItem, error) {
// Default to Russian if locale is empty or invalid
if locale == "" {
locale = "ru"
}
if locale != "ru" && locale != "en" && locale != "tt" {
locale = "ru"
}
var items []domain.TimelineItem
query := r.DB()
// Filter by heritage flag if requested
if heritageOnly {
query = query.Where("heritage IS TRUE")
}
if err := query.Order(`"order" ASC`).Find(&items).Error; err != nil {
return nil, err
}
// Apply localization if not Russian
if locale != "ru" {
for i := range items {
item := &items[i]
if localizedTitle := r.getLocalizedValue("timeline_item", item.ID, "title", locale); localizedTitle != "" {
item.Title = localizedTitle
}
if localizedContent := r.getLocalizedValue("timeline_item", item.ID, "content", locale); localizedContent != "" {
item.Content = localizedContent
}
}
}
return items, nil
}
// GetByID retrieves a timeline item by ID
func (r *TimelineRepository) GetByID(ctx context.Context, id string) (*domain.TimelineItem, error) {
return r.BaseRepository.GetByID(ctx, id)
}
// GetHeritageItems retrieves only timeline items marked for heritage display
func (r *TimelineRepository) GetHeritageItems(locale string) ([]domain.TimelineItem, error) {
return r.GetAll(locale, true)
}
// Create creates a new timeline item
func (r *TimelineRepository) Create(ctx context.Context, item *domain.TimelineItem) error {
return r.BaseRepository.Create(ctx, item)
}
// Update updates a timeline item
func (r *TimelineRepository) Update(ctx context.Context, item *domain.TimelineItem) error {
return r.BaseRepository.Update(ctx, item)
}
// Delete deletes a timeline item by ID
func (r *TimelineRepository) Delete(ctx context.Context, id string) error {
return r.BaseRepository.Delete(ctx, id)
}
// GetByHeritageFlag retrieves timeline items by heritage flag
func (r *TimelineRepository) GetByHeritageFlag(ctx context.Context, isHeritage bool) ([]domain.TimelineItem, error) {
var items []domain.TimelineItem
query := r.DB().WithContext(ctx)
if isHeritage {
query = query.Where(&domain.TimelineItem{Heritage: sql.NullBool{Bool: true, Valid: true}})
} else {
// For false, we need to find items where heritage is either false or null
query = query.Where("heritage IS NULL OR heritage = false")
}
if err := query.Order(`"order" ASC`).Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}

View File

@ -3,6 +3,7 @@ package repository
import (
"bugulma/backend/internal/domain"
"context"
"math"
"time"
"gorm.io/gorm"
@ -213,9 +214,11 @@ func (r *HistoricalSuccessRepository) CalculateTrustScore(ctx context.Context, o
trustScore.HistoricalScore = historicalScore
trustScore.ScoreBreakdown["historical"] = historicalScore
// Peer review score (placeholder)
trustScore.PeerReviewScore = 0.5
trustScore.ScoreBreakdown["peer_review"] = 0.5
// Peer review score - calculated from historical success metrics
// Organizations with successful matches and high completion rates have better peer review scores
peerReviewScore := calculatePeerReviewScore(historical, metrics)
trustScore.PeerReviewScore = peerReviewScore
trustScore.ScoreBreakdown["peer_review"] = peerReviewScore
// Overall score (weighted average)
trustScore.OverallScore = (dataQualityScore*0.3 + verificationScore*0.3 + historicalScore*0.3 + trustScore.PeerReviewScore*0.1)
@ -359,3 +362,53 @@ func generateTrustRecommendations(trustScore *domain.TrustScore) []string {
return recommendations
}
// calculatePeerReviewScore calculates peer review score based on historical performance
// This uses historical success metrics to infer peer satisfaction
func calculatePeerReviewScore(historical []*domain.HistoricalSuccess, metrics []*domain.TrustMetrics) float64 {
if len(historical) == 0 {
return 0.4 // Neutral default for new organizations
}
// Calculate average completion rate (indicates successful partnerships)
totalCompletion := 0.0
completionCount := 0
// Calculate average satisfaction if available
totalSatisfaction := 0.0
satisfactionCount := 0
for _, h := range historical {
if h.MetricType == "completion_rate" {
totalCompletion += h.Value
completionCount++
} else if h.MetricType == "satisfaction" {
totalSatisfaction += h.Value
satisfactionCount++
}
}
// Base score from completion rate (0.6 weight)
completionScore := 0.4
if completionCount > 0 {
avgCompletion := totalCompletion / float64(completionCount)
completionScore = avgCompletion * 0.6
}
// Satisfaction score (0.4 weight)
satisfactionScore := 0.3
if satisfactionCount > 0 {
avgSatisfaction := totalSatisfaction / float64(satisfactionCount)
satisfactionScore = avgSatisfaction * 0.4
}
// Combine scores
peerScore := completionScore + satisfactionScore
// Boost score if organization has many successful partnerships
if len(historical) > 10 {
peerScore = math.Min(1.0, peerScore*1.1)
}
return math.Max(0.0, math.Min(1.0, peerScore))
}

View File

@ -2,6 +2,8 @@ package repository
import (
"context"
"encoding/json"
"strings"
"bugulma/backend/internal/domain"
@ -11,12 +13,14 @@ import (
// UserRepository implements domain.UserRepository with GORM
type UserRepository struct {
*BaseRepository[domain.User]
db *gorm.DB
}
// NewUserRepository creates a new GORM-based user repository
func NewUserRepository(db *gorm.DB) domain.UserRepository {
return &UserRepository{
BaseRepository: NewBaseRepository[domain.User](db),
db: db,
}
}
@ -34,3 +38,123 @@ func (r *UserRepository) GetByID(ctx context.Context, id string) (*domain.User,
func (r *UserRepository) Create(ctx context.Context, user *domain.User) error {
return r.BaseRepository.Create(ctx, user)
}
// Update updates a user (inherited from BaseRepository)
// Delete deletes a user (inherited from BaseRepository)
// List retrieves users with filters and pagination
func (r *UserRepository) List(ctx context.Context, filters domain.UserListFilters, pagination domain.PaginationParams) (*domain.PaginatedResult[domain.User], error) {
var users []domain.User
var total int64
query := r.db.WithContext(ctx).Model(&domain.User{})
// Apply filters
if filters.Role != nil {
query = query.Where("role = ?", *filters.Role)
}
if filters.IsActive != nil {
query = query.Where("is_active = ?", *filters.IsActive)
}
if filters.Search != "" {
search := "%" + strings.ToLower(filters.Search) + "%"
query = query.Where("LOWER(email) LIKE ? OR LOWER(name) LIKE ?", search, search)
}
// Get total count
if err := query.Count(&total).Error; err != nil {
return nil, err
}
// Apply pagination and get results
result := query.
Order("created_at DESC").
Limit(pagination.Limit).
Offset(pagination.Offset).
Find(&users)
if result.Error != nil {
return nil, result.Error
}
return &domain.PaginatedResult[domain.User]{
Items: users,
Total: total,
}, nil
}
// UpdateRole updates a user's role
func (r *UserRepository) UpdateRole(ctx context.Context, userID string, role domain.UserRole) error {
result := r.db.WithContext(ctx).
Model(&domain.User{}).
Where("id = ?", userID).
Update("role", role)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrNotFound
}
return nil
}
// UpdatePermissions updates a user's permissions
func (r *UserRepository) UpdatePermissions(ctx context.Context, userID string, permissions []string) error {
permissionsJSON, err := json.Marshal(permissions)
if err != nil {
return err
}
result := r.db.WithContext(ctx).
Model(&domain.User{}).
Where("id = ?", userID).
Update("permissions", string(permissionsJSON))
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrNotFound
}
return nil
}
// Deactivate deactivates a user
func (r *UserRepository) Deactivate(ctx context.Context, userID string) error {
result := r.db.WithContext(ctx).
Model(&domain.User{}).
Where("id = ?", userID).
Update("is_active", false)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrNotFound
}
return nil
}
// Activate activates a user
func (r *UserRepository) Activate(ctx context.Context, userID string) error {
result := r.db.WithContext(ctx).
Model(&domain.User{}).
Where("id = ?", userID).
Update("is_active", true)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrNotFound
}
return nil
}
// UpdateLastLogin updates the user's last login timestamp
func (r *UserRepository) UpdateLastLogin(ctx context.Context, userID string) error {
result := r.db.WithContext(ctx).
Model(&domain.User{}).
Where("id = ?", userID).
Update("last_login_at", gorm.Expr("NOW()"))
if result.Error != nil {
return result.Error
}
return nil
}

View File

@ -0,0 +1,126 @@
package routes
import (
"bugulma/backend/internal/handler"
"bugulma/backend/internal/middleware"
"bugulma/backend/internal/service"
"github.com/gin-gonic/gin"
)
// RegisterAdminRoutes registers all admin routes
func RegisterAdminRoutes(
protected *gin.RouterGroup,
userHandler *handler.UserHandler,
orgAdminHandler *handler.OrganizationAdminHandler,
i18nHandler *handler.I18nHandler,
contentHandler *handler.ContentHandler,
adminHandler *handler.AdminHandler,
authService *service.AuthService,
) {
// All admin routes require authentication and admin role
admin := protected.Group("/admin")
// Apply AuthMiddleware first to set user_role in context
admin.Use(middleware.AuthMiddleware(authService))
// Then check for admin role
admin.Use(middleware.RequireRole("admin"))
// User Management
users := admin.Group("/users")
{
users.GET("", userHandler.ListUsers)
users.GET("/stats", userHandler.GetUserStats)
users.GET("/:id", userHandler.GetUser)
users.POST("", userHandler.CreateUser)
users.PUT("/:id", userHandler.UpdateUser)
users.PATCH("/:id/role", userHandler.UpdateRole)
users.PATCH("/:id/permissions", userHandler.UpdatePermissions)
users.DELETE("/:id", userHandler.DeactivateUser)
users.GET("/:id/activity", userHandler.GetUserActivity)
}
// Organization Management (Admin)
organizations := admin.Group("/organizations")
{
organizations.GET("/verification-queue", orgAdminHandler.GetVerificationQueue)
organizations.POST("/:id/verify", orgAdminHandler.VerifyOrganization)
organizations.POST("/:id/reject", orgAdminHandler.RejectVerification)
organizations.POST("/bulk-verify", orgAdminHandler.BulkVerify)
organizations.GET("/stats", orgAdminHandler.GetOrganizationStats)
}
// Localization Management (Admin)
i18n := admin.Group("/i18n")
{
// UI Translations
ui := i18n.Group("/ui")
{
ui.PUT("/:locale/:key", i18nHandler.UpdateUITranslation)
ui.POST("/bulk-update", i18nHandler.BulkUpdateUITranslations)
ui.POST("/auto-translate", i18nHandler.AutoTranslateMissing)
ui.GET("/:locale/keys", i18nHandler.GetTranslationKeys)
}
// Data Translations
data := i18n.Group("/data")
{
data.PUT("/:entityType/:entityID/:field/:locale", i18nHandler.UpdateDataTranslation)
data.POST("/bulk-translate", i18nHandler.BulkTranslateData)
data.GET("/:entityType/missing", i18nHandler.GetMissingTranslations)
}
}
// Content Management
content := admin.Group("/content")
{
// Static Pages
pages := content.Group("/pages")
{
pages.GET("", contentHandler.ListPages)
pages.GET("/:id", contentHandler.GetPage)
pages.POST("", contentHandler.CreatePage)
pages.PUT("/:id", contentHandler.UpdatePage)
pages.DELETE("/:id", contentHandler.DeletePage)
pages.POST("/:id/publish", contentHandler.PublishPage)
}
// Announcements
announcements := content.Group("/announcements")
{
announcements.GET("", contentHandler.ListAnnouncements)
announcements.GET("/:id", contentHandler.GetAnnouncement)
announcements.POST("", contentHandler.CreateAnnouncement)
announcements.PUT("/:id", contentHandler.UpdateAnnouncement)
announcements.DELETE("/:id", contentHandler.DeleteAnnouncement)
}
// Media Assets
media := content.Group("/media")
{
media.GET("", contentHandler.ListMediaAssets)
media.GET("/:id", contentHandler.GetMediaAsset)
media.POST("", contentHandler.CreateMediaAsset)
media.PUT("/:id", contentHandler.UpdateMediaAsset)
media.DELETE("/:id", contentHandler.DeleteMediaAsset)
}
}
// Dashboard & Analytics
dashboard := admin.Group("/dashboard")
{
dashboard.GET("/stats", adminHandler.GetDashboardStats)
dashboard.GET("/activity", adminHandler.GetRecentActivity)
}
analytics := admin.Group("/analytics")
{
analytics.GET("/organizations", adminHandler.GetOrganizationStats)
analytics.GET("/users", adminHandler.GetUserActivityStats)
analytics.GET("/matching", adminHandler.GetMatchingStats)
}
system := admin.Group("/system")
{
system.GET("/health", adminHandler.GetSystemHealth)
}
}

Some files were not shown because too many files have changed in this diff Show More