mirror of
https://github.com/SamyRai/turash.git
synced 2025-12-26 23:01:33 +00:00
Remove Turash brand identity and guidelines document
This commit is contained in:
parent
0df4812c82
commit
4a38490104
Binary file not shown.
1065
ADMIN_PANEL_CONCEPT.md
Normal file
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
753
COMMUNITY_FEATURES_PROPOSAL.md
Normal file
753
COMMUNITY_FEATURES_PROPOSAL.md
Normal 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
|
||||
|
||||
418
COMMUNITY_FEATURES_QUICK_WINS.md
Normal file
418
COMMUNITY_FEATURES_QUICK_WINS.md
Normal 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
|
||||
|
||||
375
COMMUNITY_FEATURES_STATUS_REPORT.md
Normal file
375
COMMUNITY_FEATURES_STATUS_REPORT.md
Normal 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
|
||||
|
||||
368
COMMUNITY_FUNCTIONALITY_WORKFLOW_REPORT.md
Normal file
368
COMMUNITY_FUNCTIONALITY_WORKFLOW_REPORT.md
Normal 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
|
||||
|
||||
854
COMMUNITY_INTEGRATION_STRATEGY.md
Normal file
854
COMMUNITY_INTEGRATION_STRATEGY.md
Normal 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
|
||||
|
||||
@ -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
|
||||
@ -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.*
|
||||
327
ORIGINAL_FEATURES_SUMMARY.md
Normal file
327
ORIGINAL_FEATURES_SUMMARY.md
Normal 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
|
||||
|
||||
958
PRODUCT_SERVICE_DISCOVERY_CONCEPT.md
Normal file
958
PRODUCT_SERVICE_DISCOVERY_CONCEPT.md
Normal 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
|
||||
|
||||
684
PRODUCT_SERVICE_DISCOVERY_IMPLEMENTATION.md
Normal file
684
PRODUCT_SERVICE_DISCOVERY_IMPLEMENTATION.md
Normal 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
|
||||
|
||||
@ -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
|
||||
32
README.md
32
README.md
@ -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
|
||||
|
||||
|
||||
663
SMALL_CITY_PROBLEMS_SOLUTIONS.md
Normal file
663
SMALL_CITY_PROBLEMS_SOLUTIONS.md
Normal 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
|
||||
|
||||
@ -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
@ -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
43
bugulma/Makefile
Normal 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
|
||||
@ -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
6
bugulma/Procfile
Normal 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
BIN
bugulma/backend/cli
Executable file
Binary file not shown.
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
278
bugulma/backend/cmd/cli/cmd/user.go
Normal file
278
bugulma/backend/cmd/cli/cmd/user.go
Normal 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
|
||||
}
|
||||
53
bugulma/backend/internal/domain/activity.go
Normal file
53
bugulma/backend/internal/domain/activity.go
Normal 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)
|
||||
}
|
||||
148
bugulma/backend/internal/domain/community_listing.go
Normal file
148
bugulma/backend/internal/domain/community_listing.go
Normal 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)
|
||||
}
|
||||
184
bugulma/backend/internal/domain/content.go
Normal file
184
bugulma/backend/internal/domain/content.go
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
200
bugulma/backend/internal/domain/subscription.go
Normal file
200
bugulma/backend/internal/domain/subscription.go
Normal 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)
|
||||
}
|
||||
186
bugulma/backend/internal/domain/timeline.go
Normal file
186
bugulma/backend/internal/domain/timeline.go
Normal 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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
73
bugulma/backend/internal/geospatial/geo_helper.go
Normal file
73
bugulma/backend/internal/geospatial/geo_helper.go
Normal 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
|
||||
}
|
||||
45
bugulma/backend/internal/geospatial/geo_helper_test.go
Normal file
45
bugulma/backend/internal/geospatial/geo_helper_test.go
Normal 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))
|
||||
}
|
||||
@ -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,
|
||||
?
|
||||
)
|
||||
`
|
||||
|
||||
106
bugulma/backend/internal/handler/admin_handler.go
Normal file
106
bugulma/backend/internal/handler/admin_handler.go
Normal 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{}{})
|
||||
}
|
||||
@ -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),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
519
bugulma/backend/internal/handler/content_handler.go
Normal file
519
bugulma/backend/internal/handler/content_handler.go
Normal 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"})
|
||||
}
|
||||
561
bugulma/backend/internal/handler/discovery_handler.go
Normal file
561
bugulma/backend/internal/handler/discovery_handler.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
212
bugulma/backend/internal/handler/heritage_handler_test.go
Normal file
212
bugulma/backend/internal/handler/heritage_handler_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
444
bugulma/backend/internal/handler/i18n_handler.go
Normal file
444
bugulma/backend/internal/handler/i18n_handler.go
Normal 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"})
|
||||
}
|
||||
@ -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()
|
||||
|
||||
166
bugulma/backend/internal/handler/organization_admin_handler.go
Normal file
166
bugulma/backend/internal/handler/organization_admin_handler.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
401
bugulma/backend/internal/handler/subscription_handler.go
Normal file
401
bugulma/backend/internal/handler/subscription_handler.go
Normal 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,
|
||||
})
|
||||
}
|
||||
335
bugulma/backend/internal/handler/user_handler.go
Normal file
335
bugulma/backend/internal/handler/user_handler.go
Normal 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)
|
||||
}
|
||||
71
bugulma/backend/internal/localization/entity.go
Normal file
71
bugulma/backend/internal/localization/entity.go
Normal 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 != ""
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
9
bugulma/backend/internal/localization/interfaces.go
Normal file
9
bugulma/backend/internal/localization/interfaces.go
Normal 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]
|
||||
70
bugulma/backend/internal/localization/register.go
Normal file
70
bugulma/backend/internal/localization/register.go
Normal 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()
|
||||
}
|
||||
67
bugulma/backend/internal/localization/registry.go
Normal file
67
bugulma/backend/internal/localization/registry.go
Normal 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{}
|
||||
}
|
||||
4
bugulma/backend/internal/localization/types.go
Normal file
4
bugulma/backend/internal/localization/types.go
Normal file
@ -0,0 +1,4 @@
|
||||
package localization
|
||||
|
||||
// This file is kept for backward compatibility
|
||||
// The actual interface definitions are in interfaces.go
|
||||
349
bugulma/backend/internal/matching/discovery_matcher.go
Normal file
349
bugulma/backend/internal/matching/discovery_matcher.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
62
bugulma/backend/internal/middleware/i18n.go
Normal file
62
bugulma/backend/internal/middleware/i18n.go
Normal 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
|
||||
}
|
||||
|
||||
117
bugulma/backend/internal/repository/activity_repository.go
Normal file
117
bugulma/backend/internal/repository/activity_repository.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
150
bugulma/backend/internal/repository/content_repository.go
Normal file
150
bugulma/backend/internal/repository/content_repository.go
Normal 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)
|
||||
}
|
||||
181
bugulma/backend/internal/repository/generic_entity_repository.go
Normal file
181
bugulma/backend/internal/repository/generic_entity_repository.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
397
bugulma/backend/internal/repository/localization_repository.go
Normal file
397
bugulma/backend/internal/repository/localization_repository.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
265
bugulma/backend/internal/repository/subscription_repository.go
Normal file
265
bugulma/backend/internal/repository/subscription_repository.go
Normal 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)
|
||||
}
|
||||
117
bugulma/backend/internal/repository/timeline_repository.go
Normal file
117
bugulma/backend/internal/repository/timeline_repository.go
Normal 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
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
126
bugulma/backend/internal/routes/admin.go
Normal file
126
bugulma/backend/internal/routes/admin.go
Normal 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
Loading…
Reference in New Issue
Block a user