Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
208 changes: 208 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
# DevBits Agent Guidelines

This document provides essential information for AI coding agents working on the DevBits codebase.

## Project Overview

DevBits is a social media mobile application for developers, built with:
- **Backend**: Go 1.24 + Gin framework + PostgreSQL/SQLite
- **Frontend**: React Native 0.81.5 + Expo SDK 54 + TypeScript 5.3
- **Architecture**: Monorepo with separate `/backend` and `/frontend` directories

## Build, Lint, and Test Commands

### Frontend (run from `/frontend` directory)

```bash
# Development
npm run frontend # Start Expo dev server
npm run android # Start on Android device/emulator
npm run ios # Start on iOS device/simulator

# Testing
npm test # Run Jest tests
npm test -- ComponentName # Run tests for specific component

# Linting
npm run lint # Run ESLint with Expo config

# Building
npx eas build -p android --profile production # Build Android APK/AAB
npx eas build -p ios --profile production # Build iOS app
```

### Backend (run from `/backend` directory)

```bash
# Development
go run ./api # Start API server locally

# Testing
cd backend
go test ./api/internal/tests/ # Run all tests
go test ./api/internal/tests/ -v # Run with verbose output
go test ./api/internal/tests/ -run TestUsers # Run specific test

# Building
go build -o bin/api ./api # Build binary

# Docker
docker compose up -d # Start all services (Postgres, API, Nginx)
docker compose up -d --build # Rebuild and restart
docker compose logs -f backend # View backend logs
```

## Code Style Guidelines

### TypeScript/React Native (Frontend)

#### File Organization
- Use PascalCase for component files: `Post.tsx`, `UserCard.tsx`
- Use camelCase for utility/hook files: `useAppColors.ts`, `api.ts`
- Components go in `/frontend/components/`
- Pages use Expo Router file-based routing in `/frontend/app/`
- Custom hooks in `/frontend/hooks/`
- API services in `/frontend/services/`

#### Imports
- Group imports: React/React Native → third-party → local
- Use path alias `@/` for imports: `import { useAppColors } from "@/hooks/useAppColors"`
- Order: components, hooks, contexts, services, types, constants

```typescript
import React, { useCallback, useEffect, useState } from "react";
import { View, StyleSheet, Pressable } from "react-native";
import { useRouter } from "expo-router";
import { ThemedText } from "@/components/ThemedText";
import { useAppColors } from "@/hooks/useAppColors";
import { getPostsFeed } from "@/services/api";
import { UiPost } from "@/constants/Types";
```

#### TypeScript Types
- Define interfaces in `/frontend/constants/Types.ts` for API models
- Use type inference where obvious: `const [count, setCount] = useState(0)`
- Explicit types for function parameters and returns:
```typescript
const mapPostToUi = (post: PostProps): UiPost => { ... }
```
- Use `interface` for object shapes, `type` for unions/intersections

#### Component Style
- Functional components with hooks
- Export default for page components, named exports for reusable components
- Use `useMemo` and `useCallback` for performance optimization
- Prefer StyleSheet.create() for styles at bottom of file
- Use Reanimated for complex animations, Animated API for simple ones

#### Naming Conventions
- Components: PascalCase (`Post`, `UserCard`)
- Hooks: camelCase with `use` prefix (`useAppColors`, `useMotionConfig`)
- Functions/variables: camelCase (`getCachedCommentCount`, `postData`)
- Constants: UPPER_SNAKE_CASE or camelCase for config objects
- Event handlers: `onPress`, `handleSubmit`, etc.

#### Error Handling
- Use try/catch for async operations
- Show user-friendly error messages via Alert.alert()
- Log errors for debugging: `console.error("Failed to fetch:", error)`
- Handle loading and error states in UI

### Go (Backend)

#### File Organization
- Package per feature: `handlers/`, `database/`, `auth/`, `logger/`
- Route handlers in `/backend/api/internal/handlers/*_routes.go`
- Database queries in `/backend/api/internal/database/*_queries.go`
- Test files in `/backend/api/internal/tests/`

#### Imports
- Group: standard library → third-party → local
- Use explicit package names for local imports:
```go
import (
"database/sql"
"fmt"
"net/http"

"github.com/gin-gonic/gin"

"backend/api/internal/database"
"backend/api/internal/logger"
)
```

#### Naming Conventions
- Exported functions/types: PascalCase (`GetUserById`, `ApiUser`)
- Unexported: camelCase (`setupTestRouter`, `respondWithError`)
- Interfaces: PascalCase, often with `-er` suffix (`Handler`, `Querier`)
- Constants: PascalCase or ALL_CAPS for package-level

#### Error Handling
- Return errors explicitly: `func GetUser(id int) (*User, error)`
- Use `fmt.Errorf` with `%w` for error wrapping: `fmt.Errorf("failed to parse: %w", err)`
- Check errors immediately: `if err != nil { return err }`
- Use `RespondWithError(context, status, message)` in handlers
- Log errors using the logger package

#### Types and Structs
- Use struct tags for JSON/DB mapping:
```go
type ApiUser struct {
Id int `json:"id"`
Username string `json:"username" binding:"required"`
}
```
- Pointer receivers for methods that modify state
- Value receivers for read-only methods

#### Database
- Use parameterized queries with `$1, $2, ...` placeholders (PostgreSQL style)
- Always check `sql.ErrNoRows` when expecting single results
- Use `json.Marshal/Unmarshal` for JSON columns (links, settings)
- Transaction handling for multi-step operations

#### Testing
- Use table-driven tests with `TestCase` structs
- Test setup: create in-memory SQLite DB, initialize tables
- Use `httptest.NewServer` for HTTP testing (no external network)
- Use `testify/assert` for assertions: `assert.Equal(t, expected, actual)`
- Sequential test execution to avoid race conditions

## Common Patterns

### Frontend
- Context for global state (Auth, Notifications, Preferences, Saved)
- Custom hooks for reusable logic (colors, motion, auto-refresh)
- Event emitters for real-time updates (`postEvents.ts`, `projectEvents.ts`)
- Caching strategies for performance (comment counts, media URLs)

### Backend
- JWT middleware: `handlers.RequireAuth()` protects routes
- CORS configured for local and production origins
- File uploads to `/uploads` directory with media ingestion
- WebSocket support for real-time features
- Health check endpoint: `/health`

## Testing Guidelines

- **Write tests** for new API endpoints and database queries
- **Frontend**: Test components with Jest + React Test Renderer
- **Backend**: Use table-driven tests, test both success and error cases
- **Run tests** before committing significant changes
- Test file naming: `*_test.go` (Go), `*-test.tsx` (TypeScript)

## Important Notes

- **Never commit** `.env` files or credentials
- **Database migrations**: Schema in `create_tables.sql`
- **Media files**: Images/videos go to `/backend/uploads/`
- **API base URL**: Production uses `https://devbits.ddns.net`
- **Deep linking**: Custom scheme `devbits://`
- **Version**: Frontend v1.0.2, Android versionCode 14

## Documentation

- Main instructions: `/INSTRUCTIONS.md`
- Database scripts: `/backend/scripts/README.md`
- Publishing guide: `/README_PUBLISHING.md`
5 changes: 3 additions & 2 deletions backend/api/internal/database/direct_message_queries.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type DirectMessage struct {

type DirectMessageThread struct {
PeerUsername string `json:"peer_username"`
PeerPicture string `json:"peer_picture"`
LastContent string `json:"last_content"`
LastAt time.Time `json:"last_at"`
}
Expand Down Expand Up @@ -209,7 +210,7 @@ func QueryDirectMessageThreads(username string, start int, count int) ([]DirectM
FROM directmessages dm
WHERE dm.sender_id = $1 OR dm.recipient_id = $1
)
SELECT u.username, rt.content, rt.creation_date
SELECT u.username, COALESCE(u.picture, ''), rt.content, rt.creation_date
FROM ranked_threads rt
JOIN users u ON u.id = rt.peer_id
WHERE rt.rank_in_thread = 1
Expand All @@ -225,7 +226,7 @@ func QueryDirectMessageThreads(username string, start int, count int) ([]DirectM
threads := make([]DirectMessageThread, 0)
for rows.Next() {
var thread DirectMessageThread
if err := rows.Scan(&thread.PeerUsername, &thread.LastContent, &thread.LastAt); err != nil {
if err := rows.Scan(&thread.PeerUsername, &thread.PeerPicture, &thread.LastContent, &thread.LastAt); err != nil {
return nil, http.StatusInternalServerError, fmt.Errorf("failed to scan direct message thread: %w", err)
}
threads = append(threads, thread)
Expand Down
45 changes: 45 additions & 0 deletions backend/api/internal/database/user_queries.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,51 @@ func DeleteUser(username string) error {
return nil
}

// SearchUsers retrieves users whose username starts with the given prefix (case-insensitive), limited to the specified limit.
func SearchUsers(prefix string, limit int) ([]*ApiUser, error) {
query := `
SELECT id, username, picture, bio, links, settings, creation_date
FROM users
WHERE LOWER(username) LIKE LOWER($1) AND id > 0
ORDER BY username ASC
LIMIT $2;
`
rows, err := DB.Query(query, prefix+"%", limit)
if err != nil {
return nil, fmt.Errorf("failed to search users: %w", err)
}
defer rows.Close()

var users []*ApiUser
for rows.Next() {
user := &ApiUser{}
var links, settings []byte
if err := rows.Scan(
&user.Id,
&user.Username,
&user.Picture,
&user.Bio,
&links,
&settings,
&user.CreationDate,
); err != nil {
return nil, fmt.Errorf("failed to scan user row: %w", err)
}
if err := json.Unmarshal(links, &user.Links); err != nil {
log.Printf("WARN: could not unmarshal user links: %v", err)
}
if err := json.Unmarshal(settings, &user.Settings); err != nil {
log.Printf("WARN: could not unmarshal user settings: %v", err)
}
users = append(users, user)
}

if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to iterate over user search results: %w", err)
}
return users, nil
}

// GetUsers retrieves a list of all users
func GetUsers() ([]*ApiUser, error) {
query := `
Expand Down
32 changes: 32 additions & 0 deletions backend/api/internal/handlers/user_routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,38 @@ import (
"github.com/gin-gonic/gin"
)

// SearchUsers handles GET /users/search?q=<prefix>&count=<n>
// Returns up to `count` users (max 20, default 10) whose username starts with `q`.
func SearchUsers(context *gin.Context) {
q := strings.TrimSpace(context.Query("q"))
if q == "" {
context.JSON(http.StatusOK, []*database.ApiUser{})
return
}

limit := 10
if strCount := context.Query("count"); strCount != "" {
if parsed, err := strconv.Atoi(strCount); err == nil && parsed > 0 {
if parsed > 20 {
parsed = 20
}
limit = parsed
}
}

users, err := database.SearchUsers(q, limit)
if err != nil {
RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Failed to search users: %v", err))
return
}

if users == nil {
users = []*database.ApiUser{}
}

context.JSON(http.StatusOK, users)
}

// GetUsernameById handles GET requests to fetch a user by their username.
// It expects the `username` parameter in the URL.
// Returns:
Expand Down
1 change: 1 addition & 0 deletions backend/api/internal/tests/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ func setupTestRouter() *gin.Engine {
router.GET("/auth/me", handlers.RequireAuth(), handlers.GetMe)

router.GET("/users", handlers.GetUsers)
router.GET("/users/search", handlers.SearchUsers)
router.GET("/users/:username", handlers.GetUserByUsername)
router.GET("/users/id/:user_id", handlers.GetUserById)
router.POST("/users", handlers.RequireAuth(), handlers.CreateUser)
Expand Down
22 changes: 22 additions & 0 deletions backend/api/internal/tests/user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,4 +199,26 @@ var user_tests = []TestCase{
ExpectedStatus: http.StatusOK,
ExpectedBody: `null`,
},

// search users – empty q returns empty array
{
Method: http.MethodGet,
Endpoint: "/users/search?q=",
ExpectedStatus: http.StatusOK,
ExpectedBody: `[]`,
},
// search users – prefix match returns matching users
{
Method: http.MethodGet,
Endpoint: "/users/search?q=dev",
ExpectedStatus: http.StatusOK,
ExpectedBody: `[{"bio":"Updated developer bio.","creation_date":"2023-12-13T00:00:00Z","id":1,"links":["https://github.com/dev_user1","https://devuser1.com"],"picture":"https://example.com/dev_user1.jpg","settings":{"accentColor":"","backgroundRefreshEnabled":false,"compactMode":false,"refreshIntervalMs":120000,"zenMode":false},"username":"dev_user1"}]`,
},
// search users – count is capped at 20
{
Method: http.MethodGet,
Endpoint: "/users/search?q=d&count=100",
ExpectedStatus: http.StatusOK,
ExpectedBody: `[{"bio":"Data scientist with a passion for machine learning.","creation_date":"2023-06-13T00:00:00Z","id":3,"links":["https://github.com/data_scientist3","https://datascientist3.com"],"picture":"https://example.com/data_scientist3.jpg","settings":{"accentColor":"","backgroundRefreshEnabled":false,"compactMode":false,"refreshIntervalMs":120000,"zenMode":false},"username":"data_scientist3"},{"bio":"Updated developer bio.","creation_date":"2023-12-13T00:00:00Z","id":1,"links":["https://github.com/dev_user1","https://devuser1.com"],"picture":"https://example.com/dev_user1.jpg","settings":{"accentColor":"","backgroundRefreshEnabled":false,"compactMode":false,"refreshIntervalMs":120000,"zenMode":false},"username":"dev_user1"}]`,
},
}
1 change: 1 addition & 0 deletions backend/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ func main() {
router.POST("/media/upload", handlers.RequireAuth(), handlers.UploadMedia)

router.GET("/users", handlers.GetUsers)
router.GET("/users/search", handlers.SearchUsers)
router.GET("/users/:username", handlers.GetUserByUsername)
router.GET("/users/id/:user_id", handlers.GetUserById)
router.POST("/users", handlers.RequireAuth(), handlers.CreateUser)
Expand Down
Empty file added backend/devbits.db
Empty file.
9 changes: 9 additions & 0 deletions frontend/app/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@ export default function TabLayout() {
),
}}
/>
<Tabs.Screen
name="message"
options={{
title: "Messages",
tabBarIcon: ({ color }) => (
<IconSymbol size={28} name="terminal.fill" color={color} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
Expand Down
1 change: 0 additions & 1 deletion frontend/app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,6 @@ export default function HomeScreen() {
const task = InteractionManager.runAfterInteractions(() => {
router.prefetch("/streams");
router.prefetch("/bytes");
router.prefetch("/terminal");
});

return () => {
Expand Down
Loading
Loading