├── .gitignore
├── backend
├── .dockerignore
├── .gitignore
├── pkg
│ ├── db
│ │ ├── migrations
│ │ │ ├── 000001_create_users.down.sql
│ │ │ ├── 000003_create_posts.down.sql
│ │ │ ├── 000002_create_sessions.down.sql
│ │ │ ├── 000006_create_comments.down.sql
│ │ │ ├── 000007_create_messages.down.sql
│ │ │ ├── 000008_create_groups.down.sql
│ │ │ ├── 000004_create_followers.down.sql
│ │ │ ├── 000017_create_notifications.down.sql
│ │ │ ├── 000005_create_post_shares.down.sql
│ │ │ ├── 000009_create_group_members.down.sql
│ │ │ ├── 000010_create_group_posts.down.sql
│ │ │ ├── 000012_create_group_events.down.sql
│ │ │ ├── 000013_create_event_options.down.sql
│ │ │ ├── 000016_create_group_comments.down.sql
│ │ │ ├── 000014_create_post_reactions.down.sql
│ │ │ ├── 000011_create_group_message_notifications.down.sql
│ │ │ ├── 000015_create_group_reactions.down.sql
│ │ │ ├── 000018_create_group_index.down.sql
│ │ │ ├── 000002_create_sessions.up.sql
│ │ │ ├── 000005_create_post_shares.up.sql
│ │ │ ├── 000003_create_posts.up.sql
│ │ │ ├── 000010_create_group_posts.up.sql
│ │ │ ├── 000004_create_followers.up.sql
│ │ │ ├── 000008_create_groups.up.sql
│ │ │ ├── 000006_create_comments.up.sql
│ │ │ ├── 000013_create_event_options.up.sql
│ │ │ ├── 000011_create_group_message_notifications.up.sql
│ │ │ ├── 000001_create_users.up.sql
│ │ │ ├── 000014_create_post_reactions.up.sql
│ │ │ ├── 000007_create_messages.up.sql
│ │ │ ├── 000012_create_group_events.up.sql
│ │ │ ├── 000016_create_group_comments.up.sql
│ │ │ ├── 000018_create_group_index.up.sql
│ │ │ ├── 000009_create_group_members.up.sql
│ │ │ ├── 000015_create_group_reactions.up.sql
│ │ │ └── 000017_create_notifications.up.sql
│ │ └── sqlite
│ │ │ └── sqlite.go
│ ├── model
│ │ ├── category.go
│ │ ├── follow.go
│ │ ├── member.go
│ │ ├── conversation.go
│ │ ├── comment.go
│ │ ├── message.go
│ │ ├── session.go
│ │ ├── reaction.go
│ │ ├── types.go
│ │ ├── group.go
│ │ ├── post.go
│ │ ├── response
│ │ │ ├── response.go
│ │ │ └── types.go
│ │ ├── event.go
│ │ ├── response.go
│ │ ├── user.go
│ │ └── request
│ │ │ ├── request.go
│ │ │ ├── register.go
│ │ │ └── types.go
│ ├── controller
│ │ ├── logout.go
│ │ ├── register.go
│ │ ├── validators.go
│ │ ├── handlers.go
│ │ ├── session.go
│ │ ├── login.go
│ │ ├── reaction.go
│ │ ├── message.go
│ │ ├── image.go
│ │ ├── profile.go
│ │ ├── groupReaction.go
│ │ ├── comment.go
│ │ ├── chat.go
│ │ ├── follow.go
│ │ ├── post.go
│ │ ├── groupComment.go
│ │ ├── postShare.go
│ │ ├── notification.go
│ │ └── app.go
│ ├── server
│ │ ├── handlerFunc.go
│ │ ├── server.go
│ │ └── start.go
│ └── repository
│ │ ├── sessioRepo.go
│ │ ├── mainRepo.go
│ │ ├── categoryRepo.go
│ │ ├── commentRepo.go
│ │ ├── groupCommentRepo.go
│ │ ├── postShareRepo.go
│ │ ├── groupReactionRepo.go
│ │ └── reactionRepo.go
├── static
│ ├── posts
│ │ ├── 2148606c-2d2b-410b-a7bf-34a3f9dfa80d_images.jpeg
│ │ ├── e2cca501-4de9-4005-8d7b-65fd3f86fef9_images.jpeg
│ │ ├── e5074b1b-29ea-44b7-bf09-f2713efdcc76_gopher.png
│ │ ├── 17d11e96-eacf-4303-b12c-c2db16d1ef5a_images-(1).jpeg
│ │ └── 02aa71d1-590f-418e-bfbb-c3f217318782_b85f3a28fd572685b0dab45537113294.jpg
│ ├── avatars
│ │ ├── 09d12f1d-8c70-4be5-a23d-fd2d44398d6f_images.jpeg
│ │ └── abecdf62-1981-4518-b345-67b89e607517_images-(1).jpeg
│ ├── group-posts
│ │ ├── b878dfa9-cf73-42f2-9eb0-45d9f16978f2_gopher.png
│ │ ├── b9e87d80-f5c4-4c49-8005-e4d082fcef6b_gopher.png
│ │ └── a9b4b471-2952-4537-af06-e8725a7577fb_nathan-rosengrun-20-weege.jpg
│ ├── post-comments
│ │ └── 5dddc628-1b3d-47b9-a0c1-a96b4215a4ed_gopher.png
│ └── group-post-comments
│ │ ├── a88d172e-d254-4c3c-963b-398bcb945cc3_images.jpeg
│ │ └── bb7725d8-d4f2-4fb7-9251-1adf16546261_images-(1).jpeg
├── main.go
├── go.mod
├── Dockerfile
└── go.sum
├── frontend
├── .dockerignore
├── app
│ ├── favicon.ico
│ ├── page.tsx
│ ├── auth
│ │ └── layout.tsx
│ ├── home
│ │ ├── layout.tsx
│ │ ├── chat
│ │ │ └── page.tsx
│ │ └── (posts)
│ │ │ └── page.tsx
│ └── layout.tsx
├── types
│ ├── follow.ts
│ ├── comment.ts
│ ├── message.ts
│ ├── event.ts
│ ├── chat.ts
│ ├── post.ts
│ ├── user.ts
│ └── group.ts
├── public
│ ├── vercel.svg
│ ├── window.svg
│ ├── file.svg
│ ├── icons
│ │ ├── left.svg
│ │ ├── logout.svg
│ │ ├── heart.svg
│ │ ├── x.svg
│ │ ├── placeholder.svg
│ │ ├── lock.svg
│ │ ├── send.svg
│ │ ├── globe.svg
│ │ ├── attachment.svg
│ │ ├── messages.svg
│ │ ├── broken-heart.svg
│ │ └── users.svg
│ ├── globe.svg
│ └── next.svg
├── api
│ ├── auth
│ │ ├── getToken.ts
│ │ ├── setSession.ts
│ │ └── clearSessionCookie.ts
│ └── messages
│ │ ├── pass-getChatData.ts
│ │ └── getMesages.ts
├── eslint.config.mjs
├── next.config.ts
├── middleware.ts
├── helpers
│ ├── ErrorProvider.tsx
│ ├── addMessage.ts
│ ├── uploadFile.ts
│ ├── GlobalAPIHelper.ts
│ └── webSocket.ts
├── .gitignore
├── tsconfig.json
├── package.json
├── Dockerfile
└── components
│ ├── ConfirmationPopup.tsx
│ ├── popup.tsx
│ ├── ErrorPopup.tsx
│ ├── group-comment.tsx
│ ├── comment.tsx
│ ├── create-event-modal.tsx
│ ├── group-invite-modal.tsx
│ ├── comment-form.tsx
│ ├── create-group-modal.tsx
│ └── create-post-modal.tsx
├── dockerRun.sh
├── ToDo
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | backend/pkg/db/forum.db
2 |
--------------------------------------------------------------------------------
/backend/.dockerignore:
--------------------------------------------------------------------------------
1 | DOCKERFILE
2 | dockerignore
--------------------------------------------------------------------------------
/backend/.gitignore:
--------------------------------------------------------------------------------
1 | *.db
2 | *.jpg
3 | *.jpeg
4 | *.png
5 | *.gif
--------------------------------------------------------------------------------
/frontend/.dockerignore:
--------------------------------------------------------------------------------
1 | .git
2 | DOCKERFILE
3 | dockerignore
4 |
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000001_create_users.down.sql:
--------------------------------------------------------------------------------
1 | DROP TABLE IF EXISTS users;
2 |
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000003_create_posts.down.sql:
--------------------------------------------------------------------------------
1 | DROP TABLE IF EXISTS posts;
2 |
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000002_create_sessions.down.sql:
--------------------------------------------------------------------------------
1 | DROP TABLE IF EXISTS sessions;
2 |
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000006_create_comments.down.sql:
--------------------------------------------------------------------------------
1 | DROP TABLE IF EXISTS comments;
2 |
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000007_create_messages.down.sql:
--------------------------------------------------------------------------------
1 | DROP TABLE IF EXISTS messages;
2 |
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000008_create_groups.down.sql:
--------------------------------------------------------------------------------
1 | DROP TABLE IF EXISTS groups;
2 |
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000004_create_followers.down.sql:
--------------------------------------------------------------------------------
1 | DROP TABLE IF EXISTS followers;
2 |
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000017_create_notifications.down.sql:
--------------------------------------------------------------------------------
1 | DROP TABLE IF EXISTS notifications;
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000005_create_post_shares.down.sql:
--------------------------------------------------------------------------------
1 | DROP TABLE IF EXISTS post_shares;
2 |
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000009_create_group_members.down.sql:
--------------------------------------------------------------------------------
1 | DROP TABLE IF EXISTS group_members;
2 |
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000010_create_group_posts.down.sql:
--------------------------------------------------------------------------------
1 | DROP TABLE IF EXISTS group_posts;
2 |
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000012_create_group_events.down.sql:
--------------------------------------------------------------------------------
1 | DROP TABLE IF EXISTS group_events;
2 |
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000013_create_event_options.down.sql:
--------------------------------------------------------------------------------
1 | DROP TABLE IF EXISTS event_options;
2 |
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000016_create_group_comments.down.sql:
--------------------------------------------------------------------------------
1 | DROP TABLE IF EXISTS group_comments;
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000014_create_post_reactions.down.sql:
--------------------------------------------------------------------------------
1 | DROP TABLE IF EXISTS post_reactions;
2 |
--------------------------------------------------------------------------------
/frontend/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lfarssi/social-network2/HEAD/frontend/app/favicon.ico
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000011_create_group_message_notifications.down.sql:
--------------------------------------------------------------------------------
1 | DROP TABLE IF EXISTS group_message_notifications;
2 |
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000015_create_group_reactions.down.sql:
--------------------------------------------------------------------------------
1 | DROP TABLE IF EXISTS group_post_reactions;
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/frontend/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from "next/navigation";
2 |
3 | export default function Home() {
4 | redirect("/home");
5 | }
6 |
--------------------------------------------------------------------------------
/frontend/types/follow.ts:
--------------------------------------------------------------------------------
1 | export interface Follow {
2 | id: number;
3 | followerId: number;
4 | followingId: number;
5 | isAccepted: number;
6 | }
--------------------------------------------------------------------------------
/frontend/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/app/auth/layout.tsx:
--------------------------------------------------------------------------------
1 | export default async function AppLayout({ children }: { children: React.ReactNode }) {
2 |
3 | return <>{children}>
4 | }
5 |
--------------------------------------------------------------------------------
/backend/pkg/model/category.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type Category struct {
4 | ID int `json:"id"`
5 | Name string `json:"category"`
6 | Post []*Post `json:"posts"`
7 | }
8 |
--------------------------------------------------------------------------------
/backend/static/posts/2148606c-2d2b-410b-a7bf-34a3f9dfa80d_images.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lfarssi/social-network2/HEAD/backend/static/posts/2148606c-2d2b-410b-a7bf-34a3f9dfa80d_images.jpeg
--------------------------------------------------------------------------------
/backend/static/posts/e2cca501-4de9-4005-8d7b-65fd3f86fef9_images.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lfarssi/social-network2/HEAD/backend/static/posts/e2cca501-4de9-4005-8d7b-65fd3f86fef9_images.jpeg
--------------------------------------------------------------------------------
/backend/static/posts/e5074b1b-29ea-44b7-bf09-f2713efdcc76_gopher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lfarssi/social-network2/HEAD/backend/static/posts/e5074b1b-29ea-44b7-bf09-f2713efdcc76_gopher.png
--------------------------------------------------------------------------------
/backend/static/avatars/09d12f1d-8c70-4be5-a23d-fd2d44398d6f_images.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lfarssi/social-network2/HEAD/backend/static/avatars/09d12f1d-8c70-4be5-a23d-fd2d44398d6f_images.jpeg
--------------------------------------------------------------------------------
/backend/static/group-posts/b878dfa9-cf73-42f2-9eb0-45d9f16978f2_gopher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lfarssi/social-network2/HEAD/backend/static/group-posts/b878dfa9-cf73-42f2-9eb0-45d9f16978f2_gopher.png
--------------------------------------------------------------------------------
/backend/static/group-posts/b9e87d80-f5c4-4c49-8005-e4d082fcef6b_gopher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lfarssi/social-network2/HEAD/backend/static/group-posts/b9e87d80-f5c4-4c49-8005-e4d082fcef6b_gopher.png
--------------------------------------------------------------------------------
/backend/static/posts/17d11e96-eacf-4303-b12c-c2db16d1ef5a_images-(1).jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lfarssi/social-network2/HEAD/backend/static/posts/17d11e96-eacf-4303-b12c-c2db16d1ef5a_images-(1).jpeg
--------------------------------------------------------------------------------
/backend/static/avatars/abecdf62-1981-4518-b345-67b89e607517_images-(1).jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lfarssi/social-network2/HEAD/backend/static/avatars/abecdf62-1981-4518-b345-67b89e607517_images-(1).jpeg
--------------------------------------------------------------------------------
/backend/static/post-comments/5dddc628-1b3d-47b9-a0c1-a96b4215a4ed_gopher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lfarssi/social-network2/HEAD/backend/static/post-comments/5dddc628-1b3d-47b9-a0c1-a96b4215a4ed_gopher.png
--------------------------------------------------------------------------------
/backend/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "log"
5 | "social-network/pkg/server"
6 | )
7 |
8 | func main() {
9 | if err := server.Start(); err != nil {
10 | log.Fatal(err)
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/backend/static/group-post-comments/a88d172e-d254-4c3c-963b-398bcb945cc3_images.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lfarssi/social-network2/HEAD/backend/static/group-post-comments/a88d172e-d254-4c3c-963b-398bcb945cc3_images.jpeg
--------------------------------------------------------------------------------
/backend/static/group-post-comments/bb7725d8-d4f2-4fb7-9251-1adf16546261_images-(1).jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lfarssi/social-network2/HEAD/backend/static/group-post-comments/bb7725d8-d4f2-4fb7-9251-1adf16546261_images-(1).jpeg
--------------------------------------------------------------------------------
/backend/static/group-posts/a9b4b471-2952-4537-af06-e8725a7577fb_nathan-rosengrun-20-weege.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lfarssi/social-network2/HEAD/backend/static/group-posts/a9b4b471-2952-4537-af06-e8725a7577fb_nathan-rosengrun-20-weege.jpg
--------------------------------------------------------------------------------
/backend/static/posts/02aa71d1-590f-418e-bfbb-c3f217318782_b85f3a28fd572685b0dab45537113294.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lfarssi/social-network2/HEAD/backend/static/posts/02aa71d1-590f-418e-bfbb-c3f217318782_b85f3a28fd572685b0dab45537113294.jpg
--------------------------------------------------------------------------------
/backend/pkg/model/follow.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type Follow struct {
4 | ID int `json:"id"`
5 | FollowerId int `json:"followerId"`
6 | FollowingId int `json:"followingId"`
7 | IsAccepted bool `json:"isAccepted"`
8 | }
9 |
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000018_create_group_index.down.sql:
--------------------------------------------------------------------------------
1 | DROP INDEX IF EXISTS idx_group_post_reactions_user_id;
2 | DROP INDEX IF EXISTS idx_group_comments_post_id;
3 | DROP INDEX IF EXISTS idx_group_comments_user_id;
4 | DROP INDEX IF EXISTS idx_notifications_receiver_group;
--------------------------------------------------------------------------------
/dockerRun.sh:
--------------------------------------------------------------------------------
1 | docker rm -f $(docker ps -aq) && docker rmi -f $(docker images -aq)
2 |
3 | cd frontend
4 | docker build -t frontend .
5 | docker run -d -p 3000:3000 --name frontend frontend
6 |
7 | cd ../backend
8 | docker build -t backend .
9 | docker run -d -p 8080:8080 --name backend backend
--------------------------------------------------------------------------------
/frontend/api/auth/getToken.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { cookies } from "next/headers";
4 |
5 | const getToken = async () => {
6 | const cookieStore = await cookies();
7 | return cookieStore.get("token")?.value || "";
8 | };
9 |
10 | export default getToken;
11 |
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000002_create_sessions.up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS sessions (
2 | id INTEGER PRIMARY KEY AUTOINCREMENT,
3 | user_id INTEGER,
4 | session TEXT,
5 | expiresAt TEXT,
6 | FOREIGN KEY (user_id) REFERENCES users (id) ON UPDATE CASCADE ON DELETE CASCADE
7 | );
8 |
--------------------------------------------------------------------------------
/frontend/types/comment.ts:
--------------------------------------------------------------------------------
1 | export interface Comment {
2 | id: number;
3 | author: string;
4 | image?: string;
5 | user: {
6 | name: string;
7 | avatar: string;
8 | };
9 | text: string;
10 | creation_date: string;
11 | user_id?: number;
12 | user_avatar?: number;
13 | }
14 |
--------------------------------------------------------------------------------
/frontend/app/home/layout.tsx:
--------------------------------------------------------------------------------
1 | import Navbar from "@/components/Navbar";
2 |
3 | export default async function AppLayout({
4 | children,
5 | }: {
6 | children: React.ReactNode;
7 | }) {
8 | return (
9 | <>
10 |
11 | {children}
12 | >
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000005_create_post_shares.up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS post_shares (
2 | id INTEGER PRIMARY KEY AUTOINCREMENT,
3 | post_id INTEGER,
4 | shared_with_user_id INTEGER,
5 | FOREIGN KEY (post_id) REFERENCES posts (id),
6 | FOREIGN KEY (shared_with_user_id) REFERENCES users (id)
7 | );
8 |
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000003_create_posts.up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS posts (
2 | id INTEGER PRIMARY KEY AUTOINCREMENT,
3 | privacy TEXT,
4 | user_id INTEGER,
5 | caption TEXT,
6 | image TEXT,
7 | creation_date DATETIME DEFAULT CURRENT_TIMESTAMP,
8 | FOREIGN KEY (user_id) REFERENCES users (id)
9 | );
10 |
--------------------------------------------------------------------------------
/frontend/api/auth/setSession.ts:
--------------------------------------------------------------------------------
1 | 'use server'
2 |
3 | import { cookies } from 'next/headers'
4 |
5 | async function setSessionCookie(token: string) {
6 | (await cookies()).set('token', token, {
7 | path: '/',
8 | httpOnly: true,
9 | maxAge: 60 * 60 * 24,
10 | })
11 | }
12 |
13 | export default setSessionCookie
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000010_create_group_posts.up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS group_posts (
2 | id INTEGER PRIMARY KEY AUTOINCREMENT,
3 | user_id INTEGER,
4 | group_id INTEGER,
5 | caption TEXT,
6 | image TEXT,
7 | creation_date DATETIME DEFAULT CURRENT_TIMESTAMP,
8 | FOREIGN KEY (user_id) REFERENCES users (id)
9 | );
10 |
--------------------------------------------------------------------------------
/backend/pkg/model/member.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type GroupMember struct {
4 | ID int `json:"id"`
5 | UserId int `json:"user_id"`
6 | InviterId int `json:"inviter_id"`
7 | GroupId int `json:"group_id"`
8 | IsAccepted bool `json:"is_accepted"`
9 | CreatingDate string `json:"creation_date"`
10 | }
11 |
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000004_create_followers.up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS followers (
2 | id INTEGER PRIMARY KEY AUTOINCREMENT,
3 | follower_id INTEGER,
4 | following_id INTEGER,
5 | is_accepted BOOLEAN NOT NULL DEFAULT FALSE,
6 | FOREIGN KEY (follower_id) REFERENCES users (id),
7 | FOREIGN KEY (following_id) REFERENCES users (id)
8 | );
9 |
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000008_create_groups.up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS groups (
2 | id INTEGER PRIMARY KEY AUTOINCREMENT,
3 | user_id INTEGER NOT NULL,
4 | title TEXT NOT NULL,
5 | description TEXT NOT NULL,
6 | image TEXT NOT NULL,
7 | creation_date DATETIME DEFAULT CURRENT_TIMESTAMP,
8 | FOREIGN KEY (user_id) REFERENCES users (id)
9 | );
10 |
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000006_create_comments.up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS comments (
2 | id INTEGER PRIMARY KEY AUTOINCREMENT,
3 | user_id INTEGER,
4 | post_id INTEGER,
5 | text TEXT,
6 | image TEXT,
7 | creation_date DATETIME DEFAULT CURRENT_TIMESTAMP,
8 | FOREIGN KEY (user_id) REFERENCES users (id),
9 | FOREIGN KEY (post_id) REFERENCES posts (id)
10 | );
11 |
--------------------------------------------------------------------------------
/frontend/public/window.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/public/file.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/types/message.ts:
--------------------------------------------------------------------------------
1 | import { User } from "./user";
2 |
3 | export interface Message {
4 | id: number;
5 | sender_id: number;
6 | recipient_id?: number;
7 | group_id?: number;
8 | text: string;
9 | created_at: string;
10 | isOwned: boolean;
11 | user: User;
12 | }
13 |
14 | export interface AddMessageEvent {
15 | type: string;
16 | message: Message;
17 | isOwned: boolean;
18 | }
19 |
--------------------------------------------------------------------------------
/backend/pkg/model/conversation.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type Conv struct {
4 | GroupId int `json:"groupId"`
5 | UserId int `json:"userId"`
6 | Image string `json:"image"`
7 | FullName string `json:"fullName"`
8 | Unreadcount int `json:"unreadcount"`
9 | LastMessage string `json:"lastmessage"`
10 | LastMessageDate string `json:"lastmessagedate"`
11 | }
12 |
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000013_create_event_options.up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS event_options (
2 | id INTEGER PRIMARY KEY AUTOINCREMENT,
3 | event_id INTEGER NOT NULL,
4 | user_id INTEGER NOT NULL,
5 | is_going BOOLEAN NOT NULL,
6 | creation_date DATETIME DEFAULT CURRENT_TIMESTAMP,
7 | FOREIGN KEY (user_id) REFERENCES users (id),
8 | FOREIGN KEY (event_id) REFERENCES group_events (id)
9 | );
10 |
--------------------------------------------------------------------------------
/backend/pkg/model/comment.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type Comment struct {
4 | ID int `json:"id"`
5 | UserId int `json:"user_id"`
6 | UserAvatar string `json:"user_avatar"`
7 | PostId int `json:"post_id"`
8 | Author string `json:"author"`
9 | Text string `json:"text"`
10 | Image string `json:"image"`
11 | CreationDate string `json:"creation_date"`
12 | }
13 |
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000011_create_group_message_notifications.up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS group_message_notifications (
2 | id INTEGER PRIMARY KEY AUTOINCREMENT,
3 | group_id INTEGER,
4 | user_id INTEGER,
5 | creation_date DATETIME DEFAULT CURRENT_TIMESTAMP,
6 | is_seen BOOLEAN DEFAULT FALSE,
7 | FOREIGN KEY (user_id) REFERENCES users (id),
8 | FOREIGN KEY (group_id) REFERENCES groups (id)
9 | );
10 |
--------------------------------------------------------------------------------
/frontend/api/auth/clearSessionCookie.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { cookies } from "next/headers";
4 |
5 | async function clearSessionCookie() {
6 | try {
7 | const cookieStore = await cookies();
8 |
9 | cookieStore.delete("token");
10 |
11 | return { success: true };
12 | } catch (error) {
13 | console.error("Error clearing session cookie:", error);
14 | }
15 | }
16 |
17 | export default clearSessionCookie;
18 |
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000001_create_users.up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE users (
2 | id INTEGER PRIMARY KEY AUTOINCREMENT,
3 | email TEXT NOT NULL UNIQUE,
4 | password TEXT NOT NULL,
5 | firstname TEXT NOT NULL,
6 | lastname TEXT NOT NULL,
7 | birth DATE NOT NULL,
8 | nickname TEXT UNIQUE,
9 | avatar TEXT,
10 | about TEXT,
11 | is_private BOOLEAN NOT NULL DEFAULT FALSE,
12 | creation_date DATETIME DEFAULT CURRENT_TIMESTAMP
13 | );
--------------------------------------------------------------------------------
/frontend/types/event.ts:
--------------------------------------------------------------------------------
1 | import { User } from "./user";
2 |
3 | export interface GroupEvent {
4 | id: number;
5 | title: string;
6 | description: string;
7 | user_id: number;
8 | group_id: number;
9 | date: string;
10 | place: string;
11 | option_1: string;
12 | option_2: string;
13 | creation_date: string;
14 | user: User;
15 | current_option?: string;
16 | opt1_users?: User[] | null;
17 | opt2_users?: User[] | null;
18 | }
19 |
--------------------------------------------------------------------------------
/backend/pkg/model/message.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type Message struct {
4 | ID int `json:"id"`
5 | SenderId int `json:"sender_id"`
6 | RecipientId int `json:"recipient_id"`
7 | IsOwned bool `json:"isOwned"`
8 | GroupId int `json:"group_id"`
9 | IsSeen int `json:"is_seen"`
10 | Text string `json:"text"`
11 | User *User `json:"user"`
12 | CreationDate string `json:"created_at"`
13 | }
14 |
--------------------------------------------------------------------------------
/backend/go.mod:
--------------------------------------------------------------------------------
1 | module social-network
2 |
3 | go 1.24.0
4 |
5 | require (
6 | github.com/golang-migrate/migrate/v4 v4.18.3
7 | github.com/google/uuid v1.6.0
8 | github.com/gorilla/websocket v1.5.0
9 | github.com/mattn/go-sqlite3 v1.14.28
10 | golang.org/x/crypto v0.36.0
11 | )
12 |
13 | require (
14 | github.com/hashicorp/errwrap v1.1.0 // indirect
15 | github.com/hashicorp/go-multierror v1.1.1 // indirect
16 | go.uber.org/atomic v1.7.0 // indirect
17 | )
18 |
--------------------------------------------------------------------------------
/backend/pkg/model/session.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/google/uuid"
7 | )
8 |
9 | type Session struct {
10 | UserID int
11 | Session string
12 | ExpiresAt time.Time
13 | }
14 |
15 | func CreateSession(userID int) *Session {
16 | session := uuid.NewString()
17 | expiresAt := time.Now().Add(12000 * time.Second)
18 | return &Session{UserID: userID, Session: session, ExpiresAt: expiresAt}
19 | }
20 |
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000014_create_post_reactions.up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS post_reactions (
2 | id INTEGER PRIMARY KEY AUTOINCREMENT,
3 | post_id INTEGER NOT NULL,
4 | user_id INTEGER NOT NULL,
5 | is_like BOOLEAN,
6 | creation_date DATETIME DEFAULT CURRENT_TIMESTAMP,
7 | FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE,
8 | FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
9 | UNIQUE(post_id, user_id)
10 | );
11 |
--------------------------------------------------------------------------------
/backend/pkg/model/reaction.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type Reaction struct {
4 | ID int `json:"id"`
5 | PostID int `json:"post_id"`
6 | UserID int `json:"user_id"`
7 | IsLike *bool `json:"is_like"`
8 | CreationDate string `json:"creation_date"`
9 | }
10 |
11 | type ReactionCounts struct {
12 | Likes int `json:"likes"`
13 | Dislikes int `json:"dislikes"`
14 | UserReaction *bool `json:"userReaction"`
15 | }
16 |
17 |
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000007_create_messages.up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS messages (
2 | id INTEGER PRIMARY KEY AUTOINCREMENT,
3 | sender_id INTEGER,
4 | recipient_id INTEGER,
5 | group_id INTEGER,
6 | is_seen INTEGER DEFAULT 0,
7 | text TEXT,
8 | creation_date DATETIME DEFAULT CURRENT_TIMESTAMP,
9 | FOREIGN KEY (sender_id) REFERENCES users (id),
10 | FOREIGN KEY (recipient_id) REFERENCES users (id),
11 | FOREIGN KEY (group_id) REFERENCES groups (id)
12 | );
13 |
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000012_create_group_events.up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS group_events (
2 | id INTEGER PRIMARY KEY AUTOINCREMENT,
3 | group_id INTEGER,
4 | user_id INTEGER,
5 | title TEXT,
6 | description TEXT,
7 | option_1 TEXT,
8 | option_2 TEXT,
9 | date DATETIME,
10 | place TEXT,
11 | creation_date DATETIME DEFAULT CURRENT_TIMESTAMP,
12 | FOREIGN KEY (user_id) REFERENCES users (id),
13 | FOREIGN KEY (group_id) REFERENCES groups (id)
14 | );
15 |
--------------------------------------------------------------------------------
/frontend/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import { dirname } from "path";
2 | import { fileURLToPath } from "url";
3 | import { FlatCompat } from "@eslint/eslintrc";
4 |
5 | const __filename = fileURLToPath(import.meta.url);
6 | const __dirname = dirname(__filename);
7 |
8 | const compat = new FlatCompat({
9 | baseDirectory: __dirname,
10 | });
11 |
12 | const eslintConfig = [
13 | ...compat.extends("next/core-web-vitals", "next/typescript"),
14 | ];
15 |
16 | export default eslintConfig;
17 |
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000016_create_group_comments.up.sql:
--------------------------------------------------------------------------------
1 | -- Create group_comments table
2 | CREATE TABLE IF NOT EXISTS group_comments (
3 | id INTEGER PRIMARY KEY AUTOINCREMENT,
4 | user_id INTEGER NOT NULL,
5 | post_id INTEGER NOT NULL,
6 | text TEXT NOT NULL,
7 | image TEXT DEFAULT '',
8 | creation_date DATETIME DEFAULT CURRENT_TIMESTAMP,
9 | FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
10 | FOREIGN KEY (post_id) REFERENCES group_posts(id) ON DELETE CASCADE
11 | );
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000018_create_group_index.up.sql:
--------------------------------------------------------------------------------
1 | CREATE INDEX IF NOT EXISTS idx_group_post_reactions_post_id ON group_post_reactions(post_id);
2 | CREATE INDEX IF NOT EXISTS idx_group_post_reactions_user_id ON group_post_reactions(user_id);
3 | CREATE INDEX IF NOT EXISTS idx_group_comments_post_id ON group_comments(post_id);
4 | CREATE INDEX IF NOT EXISTS idx_group_comments_user_id ON group_comments(user_id);
5 | CREATE INDEX IF NOT EXISTS idx_notifications_receiver_group ON notifications(receiver_id, group_id);
6 |
--------------------------------------------------------------------------------
/backend/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:1.24-bullseye AS builder
2 |
3 | WORKDIR /app
4 |
5 | COPY go.mod go.sum ./
6 | RUN go mod download
7 |
8 | COPY . .
9 |
10 | RUN go build -o backend main.go
11 |
12 | FROM debian:bullseye-slim
13 |
14 | RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
15 |
16 | WORKDIR /root/
17 |
18 | COPY --from=builder /app/backend .
19 | COPY --from=builder /app/pkg/db/migrations ./pkg/db/migrations
20 |
21 | EXPOSE 8080
22 |
23 | CMD ["./backend"]
24 |
--------------------------------------------------------------------------------
/frontend/types/chat.ts:
--------------------------------------------------------------------------------
1 | // API response Chat
2 | export interface ResChat {
3 | groupId: number;
4 | userId: number;
5 | image: string;
6 | fullName: string;
7 | unreadcount: number;
8 | lastmessagedate: string;
9 | }
10 |
11 | // React Chat Interface
12 | export interface Chat {
13 | id: string;
14 | name: string;
15 | avatar: string;
16 | lastMessage?: string;
17 | lastMessageTime?: string;
18 | unreadCount: number;
19 | isGroup: boolean;
20 | isNew: boolean;
21 | isOnline?: boolean;
22 | }
--------------------------------------------------------------------------------
/frontend/next.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from "next";
2 |
3 | const nextConfig: NextConfig = {
4 | reactStrictMode: false,
5 | images: {
6 | remotePatterns: [
7 | {
8 | protocol: "http",
9 | hostname: "localhost",
10 | port: "8080",
11 | pathname: "/getProtectedImage",
12 | },
13 | ],
14 | },
15 | experimental: {
16 | serverActions: {
17 | bodySizeLimit: "100mb",
18 | },
19 | },
20 | };
21 |
22 | export default nextConfig;
23 |
--------------------------------------------------------------------------------
/backend/pkg/controller/logout.go:
--------------------------------------------------------------------------------
1 | package app
2 |
3 | import (
4 | "log"
5 | "social-network/pkg/model/request"
6 | "social-network/pkg/model/response"
7 | )
8 |
9 | func (app *App) Logout(payload *request.RequestT) any {
10 |
11 | session := payload.Ctx.Value("token").(string)
12 | err := app.repository.Session().RemoveSession(session)
13 | if err != nil {
14 | log.Println("Failed to remove session:", err)
15 | }
16 |
17 | return &response.Logout{
18 | Message: "Session was removed!",
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/frontend/middleware.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server";
2 | import type { NextRequest } from "next/server";
3 |
4 | export function middleware(request: NextRequest) {
5 | const token = request.cookies.get("token")?.value;
6 |
7 | const isProtected =
8 | request.nextUrl.pathname.startsWith("/home") ||
9 | request.nextUrl.pathname === "/";
10 |
11 | if (!token && isProtected) {
12 | return NextResponse.redirect(new URL("/auth", request.url));
13 | }
14 |
15 | return NextResponse.next();
16 | }
17 |
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000009_create_group_members.up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS group_members (
2 | id INTEGER PRIMARY KEY AUTOINCREMENT,
3 | user_id INTEGER NOT NULL,
4 | inviter_id INTEGER NOT NULL,
5 | group_id INTEGER NOT NULL,
6 | is_accepted BOOLEAN NOT NULL,
7 | is_seen BOOLEAN DEFAULT FALSE,
8 | creation_date DATETIME DEFAULT CURRENT_TIMESTAMP,
9 | FOREIGN KEY (user_id) REFERENCES users (id),
10 | FOREIGN KEY (inviter_id) REFERENCES users (id),
11 | FOREIGN KEY (group_id) REFERENCES groups (id)
12 | );
13 |
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000015_create_group_reactions.up.sql:
--------------------------------------------------------------------------------
1 | -- Create group_post_reactions table
2 | CREATE TABLE IF NOT EXISTS group_post_reactions (
3 | id INTEGER PRIMARY KEY AUTOINCREMENT,
4 | user_id INTEGER NOT NULL,
5 | post_id INTEGER NOT NULL,
6 | is_like BOOLEAN NOT NULL,
7 | creation_date DATETIME DEFAULT CURRENT_TIMESTAMP,
8 | FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
9 | FOREIGN KEY (post_id) REFERENCES group_posts(id) ON DELETE CASCADE,
10 | UNIQUE(user_id, post_id)
11 | );
12 |
13 |
14 |
--------------------------------------------------------------------------------
/ToDo:
--------------------------------------------------------------------------------
1 | ToDo :
2 |
3 | # Chat
4 | - display 10 messages at a time with a debounce(throttle) of 1 second
5 | # Groups
6 |
7 |
8 | # Single Profile Page
9 |
10 | # Middlewares
11 | - canSendMessage
12 | - isMember / is Owner (groups hanlders)
13 |
14 |
15 | # Problems
16 | - logout
17 |
18 |
19 |
20 |
21 | #install font-awesome
22 | npm install @fortawesome/fontawesome-free
23 | npm install --save @fortawesome/react-fontawesome @fortawesome/free-solid-svg-icons @fortawesome/fontawesome-svg-core
24 |
25 |
26 | golangci-lint run ./...
27 | gocritic check ./...
--------------------------------------------------------------------------------
/frontend/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import ErrorPopup from "@/components/ErrorPopup";
3 | import "@/styles/globals.css";
4 |
5 | export const metadata: Metadata = {
6 | title: "Social Network",
7 | description: "Generated by create next app",
8 | };
9 |
10 | export default function RootLayout({
11 | children,
12 | }: Readonly<{
13 | children: React.ReactNode;
14 | }>) {
15 | return (
16 |
17 |
18 | {children}
19 |
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/frontend/public/icons/left.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/backend/pkg/db/migrations/000017_create_notifications.up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS notifications (
2 | id INTEGER PRIMARY KEY AUTOINCREMENT,
3 | sender_id INTEGER NOT NULL,
4 | receiver_id INTEGER NOT NULL,
5 | type TEXT NOT NULL, -- 'follow_request', 'event_created', 'event_response'
6 | group_id INTEGER,
7 | is_seen BOOLEAN DEFAULT FALSE,
8 |
9 |
10 | creation_date DATETIME DEFAULT CURRENT_TIMESTAMP,
11 | FOREIGN KEY (sender_id) REFERENCES users (id),
12 | FOREIGN KEY (receiver_id) REFERENCES users (id)
13 | FOREIGN KEY (group_id) REFERENCES groups(id)
14 | );
15 |
--------------------------------------------------------------------------------
/frontend/app/home/chat/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 | import ChatList from "@/components/chatList";
5 | import ChatWindow from "@/components/chatWindow";
6 | import { Chat } from "@/types/chat";
7 |
8 | export default function ChatPage() {
9 | const [activeChat, setActiveChat] = useState(null);
10 |
11 | return (
12 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/frontend/types/post.ts:
--------------------------------------------------------------------------------
1 | import { Comment } from "./comment";
2 | import { User } from "./user";
3 |
4 | export interface Post {
5 | id: number;
6 | user: User;
7 | user_id?: number;
8 | image?: string;
9 | caption?: string;
10 | privacy?: string;
11 | creation_date?: string;
12 | reactions?: Reaction;
13 | comments?: Comment[];
14 | comment_count?: number;
15 | }
16 |
17 | export interface Reaction {
18 | likes: number;
19 | dislikes: number;
20 | userReaction: boolean | null;
21 | isReacting?: boolean;
22 | }
23 |
24 | export interface PostShareModalProps {
25 | postId: number;
26 | isOpen: boolean;
27 | onClose: () => void;
28 | }
29 |
--------------------------------------------------------------------------------
/backend/pkg/model/types.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | const (
4 | TYPE_REGISTER = "register"
5 | TYPE_LOGIN = "login"
6 | Type_GET_POSTS = "get-posts"
7 | Type_GET_POST = "get-post"
8 | Type_ADD_POST = "add-post"
9 | Type_REACT_TO_POST = "react-to-post"
10 | Type_GET_POST_SHARES = "get-post-shares"
11 | Type_ADD_POST_SHARE = "add-post-share"
12 | Type_REMOVE_POST_SHARE = "remove-post-share"
13 | Type_ADD_COMMENT = "add-comment"
14 | Type_GET_COMMENTS = "get-comments"
15 | Type_GET_PROFILE = "get-profile"
16 | Type_GET_PROFILES = "get-profiles"
17 | Type_SET_PROFILE_PRIVACY = "set-privacy"
18 | )
19 |
--------------------------------------------------------------------------------
/frontend/helpers/ErrorProvider.tsx:
--------------------------------------------------------------------------------
1 | // Define the error structure
2 | export interface AppError {
3 | message: string;
4 | code?: number;
5 | }
6 |
7 | // Global error handler function reference
8 | let globalErrorHandler: (error: AppError) => void = () => {};
9 |
10 | // Setter for global error handler
11 | export const setGlobalErrorHandler = (fn: (error: AppError) => void) => {
12 | globalErrorHandler = fn;
13 | };
14 |
15 | // Getter for global error handler
16 | export const getGlobalErrorHandler = () => globalErrorHandler;
17 |
18 | // Convenience function to trigger global error
19 | export const showGlobalError = (message: string, code?: number) => {
20 | globalErrorHandler({ message, code });
21 | };
22 |
--------------------------------------------------------------------------------
/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 |
13 | # testing
14 | /coverage
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 |
20 | # production
21 | /build
22 |
23 | # misc
24 | .DS_Store
25 | *.pem
26 |
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 | .pnpm-debug.log*
32 |
33 | # env files (can opt-in for committing if needed)
34 | .env*
35 |
36 | # vercel
37 | .vercel
38 |
39 | # typescript
40 | *.tsbuildinfo
41 | next-env.d.ts
42 |
--------------------------------------------------------------------------------
/backend/pkg/model/group.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import "time"
4 |
5 | type Group struct {
6 | ID int `json:"id"`
7 | OwnerId int `json:"owner_id"`
8 | Members []*User `json:"members"`
9 | Posts []*Post `json:"posts"`
10 | Events []*GroupEvent `json:"events"`
11 | Title string `json:"title"`
12 | Description string `json:"description"`
13 | Image string `json:"image"`
14 | CreationDate time.Time `json:"creation_date"`
15 | IsOwner bool `json:"is_owner"`
16 | IsAccepted bool `json:"is_accepted"`
17 | IsPending bool `json:"is_pending"`
18 | HasNewEvent bool `json:"new_event"`
19 | Inviter User `json:"inviter"`
20 | }
21 |
--------------------------------------------------------------------------------
/frontend/public/icons/logout.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "helpers/cssProvider.tsx"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/backend/pkg/model/post.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type Post struct {
4 | ID int `json:"id"`
5 | UserId int `json:"user_id"`
6 | Author string `json:"author"`
7 | CategoryID int `json:"category_id"`
8 | Title string `json:"title"`
9 | Text string `json:"text"`
10 | CreationDate string `json:"creation_date"`
11 | Comment []*Comment `json:"comments"`
12 | Privacy string `json:"privacy"`
13 | Caption string `json:"caption"`
14 | Image string `json:"image"`
15 | User *User `json:"user"`
16 | Reactions ReactionCounts `json:"reactions"`
17 | CommentCount int `json:"comment_count"`
18 | }
19 |
--------------------------------------------------------------------------------
/frontend/helpers/addMessage.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import getToken from "@/api/auth/getToken";
4 | import { socket } from "./webSocket";
5 | import { showGlobalError } from "@/helpers/ErrorProvider";
6 |
7 | export const addMessage = async (
8 | id: number,
9 | isGroup: boolean,
10 | message: string
11 | ) => {
12 | if (!socket) return;
13 |
14 | try {
15 | const token = await getToken();
16 | if (!token) {
17 | showGlobalError("Invalid session. Please log in again.", 401);
18 | return;
19 | }
20 |
21 | socket.send(
22 | JSON.stringify({
23 | type: "add-message",
24 | data: { session: token, isGroup, id, message },
25 | })
26 | );
27 | } catch (err) {
28 | console.error(err);
29 | }
30 | };
31 |
--------------------------------------------------------------------------------
/backend/pkg/controller/register.go:
--------------------------------------------------------------------------------
1 | package app
2 |
3 | import (
4 | "social-network/pkg/model/request"
5 | "social-network/pkg/model/response"
6 | )
7 |
8 | func (app *App) Register(payload *request.RequestT) any {
9 | data, ok := payload.Data.(*request.Register)
10 | if !ok {
11 | return response.Error{Code: 400, Cause: "Invalid payload type"}
12 | }
13 |
14 | err := data.Validate()
15 | if err != nil {
16 | return err
17 | }
18 |
19 | id, err := app.repository.User().Create(data)
20 | if err != nil {
21 | return err
22 | }
23 | session, sessionErr := app.repository.Session().Create(id)
24 | if sessionErr != nil {
25 | return response.Error{Code: 500, Cause: "Failed to create a session!"}
26 | }
27 |
28 | return &response.Login{
29 | Session: session,
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/frontend/types/user.ts:
--------------------------------------------------------------------------------
1 | import { Follow } from "./follow";
2 | import { Post } from "./post";
3 |
4 | export interface User {
5 | id: number;
6 | username?: string;
7 | firstname: string;
8 | lastname: string;
9 | nickname: string;
10 | avatar?: string;
11 | online?: boolean;
12 | isaccepted?: boolean;
13 | }
14 |
15 | export interface Profile {
16 | id: number;
17 | firstname: string;
18 | lastname: string;
19 | nickname: string;
20 | email: string;
21 | birth: string;
22 | avatar: string;
23 | about: string;
24 | online: boolean;
25 | isprivate: boolean;
26 | isaccepted: boolean;
27 | follow: Follow;
28 | posts: Post[];
29 | followers: User[] | null;
30 | following: User[] | null;
31 | currentuser: boolean;
32 | totalnotifications: number;
33 | lastmessagedate: string;
34 | }
35 |
--------------------------------------------------------------------------------
/backend/pkg/server/handlerFunc.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "net/http"
5 | "social-network/pkg/model/request"
6 | "social-network/pkg/model/response"
7 | )
8 |
9 | type HandlerFunc func(*request.RequestT) any
10 |
11 | func (h HandlerFunc) ServeHTTP(w http.ResponseWriter, request *request.RequestT) {
12 | res := h(request)
13 | status, body := response.Marshal(res)
14 | w.Header().Set("Content-Type", "application/json")
15 | w.WriteHeader(status)
16 | w.Write(body)
17 | }
18 |
19 | func (h HandlerFunc) ApplyMiddlewares(request *request.RequestT) http.Handler {
20 | var handler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
21 | h.ServeHTTP(w, request)
22 | })
23 |
24 | for _, mw := range request.Middlewares {
25 | handler = mw(handler, request)
26 | }
27 |
28 | return handler
29 |
30 | }
31 |
--------------------------------------------------------------------------------
/backend/pkg/db/sqlite/sqlite.go:
--------------------------------------------------------------------------------
1 | package sqlite
2 |
3 | import (
4 | "database/sql"
5 | "log"
6 |
7 | "github.com/golang-migrate/migrate/v4"
8 | _ "github.com/golang-migrate/migrate/v4/database/sqlite3"
9 | _ "github.com/golang-migrate/migrate/v4/source/file"
10 | _ "github.com/mattn/go-sqlite3"
11 | )
12 |
13 | func InitDB(databasePath string) (*sql.DB) {
14 |
15 | runMigrations()
16 | db, err := sql.Open("sqlite3", databasePath)
17 | if err != nil {
18 | log.Fatal(err)
19 | }
20 | return db
21 | }
22 |
23 | func runMigrations() {
24 | m, err := migrate.New(
25 | "file://pkg/db/migrations",
26 | "sqlite3://pkg/db/forum.db",
27 | )
28 | if err != nil {
29 | log.Fatal(err)
30 | }
31 |
32 | if err := m.Up(); err != nil && err != migrate.ErrNoChange {
33 | log.Fatal(err)
34 | }
35 | }
36 |
37 |
--------------------------------------------------------------------------------
/frontend/api/messages/pass-getChatData.ts:
--------------------------------------------------------------------------------
1 | 'use server'
2 |
3 | import { cookies } from 'next/headers'
4 |
5 | const getChatData = async () => {
6 | try {
7 | const cookieStore = await cookies()
8 | const token = cookieStore.get('token')?.value || ''
9 |
10 | const response = await fetch("http://localhost:8080/getChatData", {
11 | method: "POST",
12 | headers: {
13 | "Content-Type": "application/json",
14 | },
15 | body: JSON.stringify({
16 | session: token
17 | }),
18 | });
19 |
20 | const data = await response.json();
21 |
22 | if (data.error == "Invalid session") {
23 | cookieStore.delete('token');
24 | }
25 |
26 | // console.log(data)
27 | return data;
28 | } catch (err) {
29 | console.error(err);
30 | }
31 | };
32 |
33 | export default getChatData
--------------------------------------------------------------------------------
/backend/pkg/controller/validators.go:
--------------------------------------------------------------------------------
1 | package app
2 |
3 | import (
4 | "regexp"
5 |
6 | "golang.org/x/crypto/bcrypt"
7 | )
8 |
9 | func IsUsernameValid(username string) bool {
10 | re := regexp.MustCompile(`^[A-Za-z]{3,20}$`)
11 | return re.MatchString(username)
12 | }
13 |
14 | func IsPasswordValid(password string) bool {
15 | return len(password) >= 6 && len(password) <= 50 && regexp.MustCompile(`[A-Za-z]`).MatchString(password) && regexp.MustCompile(`\d`).MatchString(password)
16 |
17 | }
18 |
19 | func IsEmailValid(email string) bool {
20 | re := regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,50}$`)
21 | return re.MatchString(email)
22 | }
23 |
24 | func ComparePasswords(hashedPassword string, clearPassword string) bool {
25 | err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(clearPassword))
26 | return err == nil
27 | }
28 |
--------------------------------------------------------------------------------
/backend/pkg/model/response/response.go:
--------------------------------------------------------------------------------
1 | package response
2 |
3 | import (
4 | "encoding/json"
5 | )
6 |
7 | type Payload struct {
8 | Type string `json:"type,omitempty"`
9 | Data any `json:"data,omitempty"`
10 | Error any `json:"error,omitempty"`
11 | }
12 | type Errored interface {
13 | getCode() int
14 | }
15 |
16 | type Error struct {
17 | Code int `json:"code,omitempty"`
18 | Cause string `json:"cause,omitempty"`
19 | }
20 |
21 | func (e *Error) getCode() int { return e.Code }
22 |
23 | func Marshal(data any) (status int, result []byte) {
24 | r := Payload{}
25 | status = 200
26 | switch v := data.(type) {
27 | case Errored:
28 | status = v.getCode()
29 | r.Error = data
30 | default:
31 | r.Data = data
32 | }
33 |
34 | result, err := json.Marshal(r)
35 | if err != nil {
36 | return 500, []byte("Error When Marshal Response")
37 | }
38 | return status, result
39 | }
40 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nextjs",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev --turbopack",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@fortawesome/fontawesome-free": "^6.7.2",
13 | "@fortawesome/fontawesome-svg-core": "^6.7.2",
14 | "@fortawesome/free-solid-svg-icons": "^6.7.2",
15 | "@fortawesome/react-fontawesome": "^0.2.2",
16 | "next": "^15.3.3",
17 | "npm": "^11.4.1",
18 | "react": "^19.0.0",
19 | "react-dom": "^19.0.0"
20 | },
21 | "devDependencies": {
22 | "@eslint/eslintrc": "^3",
23 | "@types/node": "^20",
24 | "@types/react": "^19",
25 | "@types/react-dom": "^19",
26 | "eslint": "^9",
27 | "eslint-config-next": "15.3.1",
28 | "typescript": "^5"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/backend/pkg/model/event.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type GroupEvent struct {
4 | ID int `json:"id"`
5 | UserId int `json:"user_id"`
6 | User *User `json:"user"`
7 | GroupId int `json:"group_id"`
8 | Title string `json:"title"`
9 | Description string `json:"description"`
10 | Option1 string `json:"option_1"`
11 | Opt1Users []*User `json:"opt1_users"`
12 | Option2 string `json:"option_2"`
13 | Opt2Users []*User `json:"opt2_users"`
14 | Date string `json:"date"`
15 | Place string `json:"place"`
16 | CurrentOption string `json:"current_option"`
17 | CreationDate string `json:"creation_date"`
18 | }
19 |
20 | type EventOption struct {
21 | ID int `json:"id"`
22 | EventId int `json:"event_id"`
23 | User *User `json:"user"`
24 | IsGoing bool `json:"isGoing"`
25 | }
26 |
--------------------------------------------------------------------------------
/frontend/public/icons/heart.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/frontend/api/messages/getMesages.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { cookies } from "next/headers";
4 |
5 | const getMessages = async (id: number, isGroup: boolean) => {
6 | try {
7 | const cookieStore = await cookies();
8 | const token = cookieStore.get("token")?.value || "";
9 |
10 | const response = await fetch("http://localhost:8080/getMessages", {
11 | method: "POST",
12 | headers: {
13 | "Content-Type": "application/json",
14 | },
15 | body: JSON.stringify({
16 | session: token,
17 | id: id,
18 | isGroup,
19 | }),
20 | });
21 | const data = await response.json();
22 |
23 | // console.log(data);
24 |
25 | if (data.error == "Invalid session") {
26 | cookieStore.delete("token");
27 | }
28 | return data;
29 | } catch (err) {
30 | console.error(err);
31 | }
32 | };
33 |
34 | export default getMessages;
35 |
--------------------------------------------------------------------------------
/frontend/Dockerfile:
--------------------------------------------------------------------------------
1 | # Base image
2 | FROM node:20-alpine AS base
3 | WORKDIR /app
4 | RUN apk add --no-cache libc6-compat
5 |
6 | # === Install dependencies (cached if package.json unchanged)
7 | FROM base AS deps
8 | COPY package*.json ./
9 | RUN npm ci
10 |
11 | # === Build app
12 | FROM base AS builder
13 | WORKDIR /app
14 | COPY --from=deps /app/node_modules ./node_modules
15 | COPY . .
16 | RUN npm run build
17 |
18 | # === Production runner
19 | FROM base AS runner
20 | WORKDIR /app
21 |
22 | ENV NODE_ENV=production
23 | EXPOSE 3000
24 |
25 | # Copy only what's needed for production
26 | COPY --from=builder /app/public ./public
27 | COPY --from=builder /app/.next ./.next
28 | COPY --from=builder /app/node_modules ./node_modules
29 | COPY --from=builder /app/package.json ./package.json
30 |
31 | # Use non-root user if needed (optional)
32 | # RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001 -G nodejs
33 | # USER nextjs
34 |
35 | CMD ["npm", "start"]
--------------------------------------------------------------------------------
/frontend/public/globe.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/pkg/controller/handlers.go:
--------------------------------------------------------------------------------
1 | package app
2 |
3 | import (
4 | "log"
5 | "net/http"
6 | )
7 |
8 | func (app *App) WebSocketHandler(w http.ResponseWriter, r *http.Request) {
9 |
10 | if r.Header.Get("Upgrade") == "websocket" {
11 | conn, err := app.upgrader.Upgrade(w, r, nil)
12 | if err != nil {
13 | log.Println(err.Error())
14 | return
15 | }
16 |
17 | token := r.FormValue("session")
18 | uid, err := app.repository.Session().FindUserIDBySession(token)
19 | if err != nil {
20 | log.Println("Session was not found!")
21 | return
22 | }
23 |
24 | client := app.addClient(uid, token, conn)
25 |
26 | go app.readMessage(conn, client)
27 |
28 | }
29 | }
30 |
31 |
32 | func (app *App) ProtectedImageHandler(w http.ResponseWriter, r *http.Request) {
33 | fullPath, ok := r.Context().Value("fullPath").(string)
34 |
35 | if !ok || fullPath == "" {
36 | http.Error(w, "Internal server error", http.StatusInternalServerError)
37 | return
38 | }
39 |
40 | http.ServeFile(w, r, fullPath)
41 | }
42 |
--------------------------------------------------------------------------------
/backend/pkg/model/response.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type Response struct {
4 | Type string `json:"type"`
5 | Data string `json:"data"`
6 | Username string `json:"username"`
7 | Userid int `json:"userid"`
8 | Session string `json:"session"`
9 | Error string `json:"error"`
10 | AllUsers []*User `json:"allusers"`
11 | FollowRequests []*User `json:"followrequests"`
12 | Categories []*Category `json:"categories"`
13 | Posts []*Post `json:"posts"`
14 | Postid int `json:"postid"`
15 | TotalNotifications int `json:"totalnotifications"`
16 | Partnerid int `json:"partnerid"`
17 | Message *Message `json:"message"`
18 | Messages []*Message `json:"messages"`
19 | User *User `json:"user"`
20 | Success bool `json:"success"`
21 | CurrentUser *User `json:"currentuser"`
22 | }
23 |
--------------------------------------------------------------------------------
/frontend/types/group.ts:
--------------------------------------------------------------------------------
1 | import { GroupEvent } from "./event";
2 | import { Post } from "./post";
3 | import { User } from "./user";
4 |
5 | export interface Group {
6 | id: number;
7 | title: string;
8 | description: string;
9 | image: string;
10 | owner_id: number;
11 | is_owner: boolean;
12 | is_accepted: boolean;
13 | is_pending?: boolean;
14 | members: User[];
15 | posts: Post[];
16 | events: GroupEvent[];
17 | new_event: boolean;
18 | }
19 |
20 | export interface GroupInvite {
21 | title: string;
22 | id: number;
23 | group_id: number;
24 | user_id: number;
25 | creation_date: string;
26 | group: Group;
27 | inviter: User;
28 | }
29 |
30 | export interface JoinRequest {
31 | id: number;
32 | title: string;
33 | description: string;
34 | image: string;
35 | creation_date: string;
36 | is_accepted: boolean;
37 | is_owner: boolean;
38 | members: User[];
39 | }
40 |
41 | export interface GroupsData {
42 | all: Group[];
43 | error: string;
44 | groupInvites: GroupInvite[] | null;
45 | joinRequests: JoinRequest[] | null;
46 | }
47 |
--------------------------------------------------------------------------------
/frontend/public/icons/x.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/frontend/public/icons/placeholder.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/backend/pkg/controller/session.go:
--------------------------------------------------------------------------------
1 | package app
2 |
3 | import (
4 | "log"
5 | "social-network/pkg/model"
6 | )
7 |
8 | func (app *App) ValidateSession(request map[string]any) *model.Response {
9 | response := &model.Response{}
10 | response.Type = "session"
11 | response.Error = "error"
12 |
13 | var session string
14 | if sessionRaw, ok := request["session"]; !ok {
15 | response.Error = "Missing 'session' field"
16 | return response
17 | } else if session, ok = sessionRaw.(string); !ok {
18 | response.Error = "'session' must be a string"
19 | return response
20 | }
21 |
22 | uid, err := app.repository.Session().FindUserIDBySession(session)
23 | if err != nil {
24 | log.Println("Session was not found!")
25 | return response
26 | }
27 |
28 | foundUser, err := app.repository.User().Find(uid)
29 | if err != nil {
30 | log.Println("User was not found!")
31 | return response
32 | }
33 |
34 | response.Error = ""
35 | response.Userid = foundUser.ID
36 | response.User = foundUser
37 | response.Session = session
38 |
39 | response.Data = ""
40 | return response
41 | }
42 |
--------------------------------------------------------------------------------
/backend/pkg/controller/login.go:
--------------------------------------------------------------------------------
1 | package app
2 |
3 | import (
4 | "log"
5 | "social-network/pkg/model/request"
6 | "social-network/pkg/model/response"
7 | )
8 |
9 | func (app *App) Login(payload *request.RequestT) any {
10 | u, ok := payload.Data.(*request.Login)
11 | if !ok {
12 | return &response.Error{
13 | Code: 400,
14 | Cause: "Invalid payload type",
15 | }
16 | }
17 | invalidCredError := &response.Error{Code: 400, Cause: "username or password invalid"}
18 | foundUser, err := app.repository.User().Find(u.Email)
19 | if err != nil {
20 | log.Println("Failed to find a user:", err)
21 | return invalidCredError
22 | }
23 |
24 | if !ComparePasswords(foundUser.EncryptedPassword, u.Password) {
25 | return invalidCredError
26 | }
27 |
28 | session, err := app.repository.Session().Create(foundUser.ID)
29 | if err != nil {
30 | log.Println("Failed to create a session:", err)
31 | return &response.Error{Code: 500, Cause: "Failed to create session"}
32 | }
33 |
34 | return &response.Login{
35 | Session: session,
36 | UserId: foundUser.ID,
37 | Username: foundUser.Username,
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/frontend/components/ConfirmationPopup.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from "react";
2 | // import "@fortawesome/fontawesome-free/css/all.min.css";
3 |
4 | interface ConfirmationPopupProps {
5 | message: string;
6 | onConfirm: () => void;
7 | onCancel: () => void;
8 | }
9 |
10 | const ConfirmationPopup: FC = ({
11 | message,
12 | onConfirm,
13 | onCancel,
14 | }) => {
15 | return (
16 |
17 |
18 |
19 |
20 |
21 |
22 |
{message}
23 |
24 |
25 |
28 |
31 |
32 |
33 |
34 | );
35 | };
36 |
37 | export default ConfirmationPopup;
38 |
--------------------------------------------------------------------------------
/backend/pkg/model/user.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import "time"
4 |
5 | type User struct {
6 | ID int `json:"id"`
7 | Username string `json:"username"`
8 | Firstname string `json:"firstname"`
9 | Lastname string `json:"lastname"`
10 | Nickname *string `json:"nickname"`
11 | Password string `json:"password"`
12 | EncryptedPassword string `json:"-"`
13 | Email string `json:"email"`
14 | Gender string `json:"gender"`
15 | Birth time.Time `json:"birth"`
16 | Avatar string `json:"avatar"`
17 | About string `json:"about"`
18 | Online bool `json:"online"`
19 | IsPrivate bool `json:"isprivate"`
20 | IsAccepted bool `json:"isaccepted"`
21 | Follow Follow `json:"follow"`
22 | Posts []*Post `json:"posts"`
23 | Followers []*User `json:"followers"`
24 | Following []*User `json:"following"`
25 | CurrentUser bool `json:"currentuser"`
26 | TotalNotifications int `json:"totalnotifications"`
27 | LastMessageDate string `json:"lastmessagedate"`
28 | }
29 |
--------------------------------------------------------------------------------
/frontend/public/icons/lock.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/frontend/public/icons/send.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/frontend/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/components/popup.tsx:
--------------------------------------------------------------------------------
1 | // FC stands for Function Component
2 | import { FC, useEffect } from "react";
3 | // import "@fortawesome/fontawesome-free/css/all.min.css";
4 |
5 | interface PopupProps {
6 | message: string;
7 | status: "success" | "failure";
8 | onClose: () => void;
9 | }
10 |
11 | const Popup: FC = ({ message, status, onClose }) => {
12 | const isSuccess = status === "success";
13 |
14 | useEffect(() => {
15 | const timer = setTimeout(onClose, 3000);
16 | return () => clearTimeout(timer);
17 | }, [onClose]);
18 |
19 | return (
20 |
21 |
24 |
25 |
26 | {isSuccess ? (
27 |
28 | ) : (
29 |
30 | )}
31 |
32 |
33 |
{message}
34 |
37 |
38 |
39 |
40 |
41 |
42 | );
43 | };
44 |
45 | export default Popup;
46 |
--------------------------------------------------------------------------------
/backend/pkg/repository/sessioRepo.go:
--------------------------------------------------------------------------------
1 | package repository
2 |
3 | import (
4 | "database/sql"
5 | "time"
6 |
7 | "github.com/google/uuid"
8 | )
9 |
10 | type SessionRepository struct {
11 | Repository *Repository
12 | }
13 |
14 | func (r *SessionRepository) Create(id int) (string, error) {
15 | session := uuid.NewString()
16 | expiresAt := time.Now().Add(12000 * time.Second)
17 | _, err := r.Repository.db.Exec(
18 | "INSERT INTO sessions (user_id, session, expiresAt) VALUES (?, ?, ?)",
19 | id,
20 | session,
21 | expiresAt,
22 | )
23 | if err != nil {
24 | return "", err
25 | }
26 | return session, nil
27 | }
28 |
29 | func (r *SessionRepository) FindUserIDBySession(session string) (int, error) {
30 | var uid int
31 | query := "SELECT user_id from sessions WHERE session = $1"
32 | if err := r.Repository.db.QueryRow(query, session).Scan(
33 | &uid); err != nil {
34 | if err == sql.ErrNoRows {
35 | return -1, sql.ErrNoRows
36 | }
37 | return -1, err
38 | }
39 | return uid, nil
40 | }
41 |
42 | func (r *SessionRepository) RemoveSession(session string) error {
43 | query := "DELETE FROM sessions WHERE session = $1"
44 | if err := r.Repository.db.QueryRow(query, session).Scan(); err != nil {
45 | if err == sql.ErrNoRows {
46 | return nil
47 | } else {
48 | return err
49 | }
50 | }
51 | return nil
52 | }
53 |
--------------------------------------------------------------------------------
/frontend/public/icons/globe.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/frontend/public/icons/attachment.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/frontend/components/ErrorPopup.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { AppError, setGlobalErrorHandler } from "@/helpers/ErrorProvider";
4 | import { useEffect, useState } from "react";
5 |
6 | export default function ErrorPopup() {
7 | const [error, setError] = useState(null);
8 |
9 | const clearError = () => setError(null);
10 |
11 | useEffect(() => {
12 | setGlobalErrorHandler((err: AppError) => {
13 | setError(err);
14 | });
15 |
16 | return () => {
17 | setGlobalErrorHandler(() => {}); // Cleanup
18 | };
19 | }, []);
20 |
21 | if (!error) return null;
22 |
23 | return (
24 |
25 |
e.stopPropagation()}
29 | >
30 |
31 |
Error {error.code || "❗"}
32 |
35 |
36 |
37 |
45 | {error.message}
46 |
47 |
48 |
49 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/backend/pkg/server/server.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "database/sql"
5 | "io"
6 | "net/http"
7 | "social-network/pkg/model/request"
8 | "social-network/pkg/model/response"
9 | "social-network/pkg/repository"
10 |
11 | controller "social-network/pkg/controller"
12 | )
13 |
14 | type Server struct {
15 | router *http.ServeMux
16 | app *controller.App
17 | }
18 |
19 | func NewServer(router *http.ServeMux, db *sql.DB) *Server {
20 | return &Server{
21 | router: router,
22 | app: controller.NewApp(repository.New(db)),
23 | }
24 | }
25 |
26 | func (s *Server) AddRoute(pattern string, handler func(*request.RequestT) any, middlewares ...func(http.Handler, *request.RequestT) http.Handler) {
27 | h := HandlerFunc(handler)
28 | s.router.HandleFunc(pattern, func(resp http.ResponseWriter, req *http.Request) {
29 | body, err := io.ReadAll(req.Body)
30 | if err != nil {
31 | s.app.ServeError(resp, &response.Error{Cause: "oops, something went wrong", Code: 500})
32 | return
33 | }
34 |
35 | _, reqData, err := request.Unmarshal(body)
36 | if err != nil {
37 | s.app.ServeError(resp, &response.Error{Cause: err.Error(), Code: 500})
38 | return
39 | }
40 |
41 | reqData.Middlewares = middlewares
42 | handler := h.ApplyMiddlewares(reqData)
43 | s.app.CookieMiddleware(handler, reqData).ServeHTTP(resp, req)
44 |
45 | })
46 | }
47 |
48 | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
49 | s.router.ServeHTTP(w, r)
50 | }
51 |
--------------------------------------------------------------------------------
/backend/pkg/repository/mainRepo.go:
--------------------------------------------------------------------------------
1 | package repository
2 |
3 | import (
4 | "database/sql"
5 | )
6 |
7 | type Repository struct {
8 | db *sql.DB
9 | }
10 |
11 | func New(db *sql.DB) *Repository {
12 | return &Repository{db: db}
13 | }
14 |
15 | func (r *Repository) User() *UserRepository {
16 | return &UserRepository{
17 | Repository: r,
18 | }
19 | }
20 |
21 | func (r *Repository) Follow() *FollowRepository {
22 | return &FollowRepository{
23 | Repository: r,
24 | }
25 | }
26 |
27 | func (r *Repository) Session() *SessionRepository {
28 | return &SessionRepository{
29 | Repository: r,
30 | }
31 | }
32 |
33 | func (r *Repository) Category() *CategoryRepository {
34 | return &CategoryRepository{
35 | Repository: r,
36 | }
37 | }
38 |
39 | func (r *Repository) Post() *PostRepository {
40 | return &PostRepository{
41 | Repository: r,
42 | }
43 | }
44 |
45 | func (r *Repository) PostShare() *PostShareRepository {
46 | return &PostShareRepository{
47 | Repository: r,
48 | }
49 | }
50 |
51 | func (r *Repository) Comment() *CommentRepository {
52 | return &CommentRepository{
53 | Repository: r,
54 | }
55 | }
56 |
57 | func (r *Repository) Message() *MessageRepository {
58 | return &MessageRepository{
59 | Repository: r,
60 | }
61 | }
62 |
63 | func (r *Repository) Reaction() *ReactionRepository {
64 | return &ReactionRepository{
65 | Repository: r,
66 | }
67 | }
68 |
69 | func (r *Repository) Group() *GroupRepository {
70 | return &GroupRepository{
71 | Repository: r,
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/backend/pkg/controller/reaction.go:
--------------------------------------------------------------------------------
1 | package app
2 |
3 | import (
4 | "log"
5 | "social-network/pkg/model"
6 | "social-network/pkg/model/request"
7 | "social-network/pkg/model/response"
8 | )
9 |
10 | func (app *App) ReactToPost(payload *request.RequestT) any {
11 | data, ok := payload.Data.(*request.ReactToPost)
12 | if !ok {
13 | return &response.Error{
14 | Code: 400, Cause: "Invalid payload type",
15 | }
16 | }
17 |
18 | userId := payload.Ctx.Value("user_id").(int)
19 |
20 | err := app.repository.Reaction().UpsertReaction(userId, data.PostId, data.Reaction)
21 | if err != nil {
22 | log.Println("Error saving reaction:", err)
23 | return &response.Error{
24 | Code: 500, Cause: "Error saving reaction: " + err.Error(),
25 | }
26 | }
27 |
28 | counts, err := app.repository.Reaction().GetReactionCounts(data.PostId)
29 | if err != nil {
30 | log.Println("Error getting reaction counts:", err)
31 | return &response.Error{
32 | Code: 500, Cause: "Error getting reaction counts: " + err.Error(),
33 | }
34 | }
35 |
36 | userReaction, err := app.repository.Reaction().GetUserReaction(userId, data.PostId)
37 | if err != nil {
38 | log.Println("Error getting user reaction:", err)
39 | return &response.Error{
40 | Code: 500, Cause: "Error getting user reaction: " + err.Error(),
41 | }
42 | }
43 | counts.UserReaction = userReaction
44 |
45 | return &response.ReactToPost{
46 | Userid: userId,
47 | Post: &model.Post{
48 | ID: data.PostId,
49 | Reactions: counts,
50 | },
51 | Success: true,
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/backend/pkg/controller/message.go:
--------------------------------------------------------------------------------
1 | package app
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "social-network/pkg/model"
7 | "social-network/pkg/model/request"
8 | )
9 |
10 | func (app *App) UpdateSeenMessage(isGroup bool, currentId, targetId int) {
11 | m := &model.Message{}
12 |
13 | // Assign after validation
14 | m.RecipientId = int(currentId)
15 |
16 | if isGroup {
17 | m.GroupId = int(targetId)
18 | err := app.repository.Message().UpdateGroupSeenMessages(m)
19 | if err != nil {
20 | log.Println("Error in updating seen messages!")
21 | }
22 | notification := map[string]any{
23 | "type": "notifications",
24 | "messages": m.GroupId ,
25 | }
26 | app.sendNotificationToUser(m.GroupId , notification)
27 | } else {
28 | m.SenderId = int(targetId)
29 |
30 | err := app.repository.Message().UpdatePmSeenMessages(m)
31 | if err != nil {
32 | log.Println("Error in updating seen messages!")
33 | }
34 | notification := map[string]any{
35 | "type": "notifications",
36 | "message": m.RecipientId ,
37 | }
38 | app.sendNotificationToUser(m.RecipientId , notification)
39 | }
40 |
41 | }
42 |
43 | func (app *App) UpdateSeenMessageWS(payload *request.RequestT) any {
44 | data, ok := payload.Data.(*request.UpdateSeenMessageWS)
45 | if !ok {
46 | fmt.Println("Invalid payload type")
47 | return nil
48 | }
49 | userId, ok := payload.Ctx.Value("user_id").(int)
50 | if !ok {
51 | fmt.Println("Invalid session")
52 | return nil
53 | }
54 |
55 | app.UpdateSeenMessage(data.IsGroup, userId, data.Id)
56 | return nil
57 | }
58 |
--------------------------------------------------------------------------------
/backend/pkg/repository/categoryRepo.go:
--------------------------------------------------------------------------------
1 | package repository
2 |
3 | import (
4 | "social-network/pkg/model"
5 | )
6 |
7 | type CategoryRepository struct {
8 | Repository *Repository
9 | }
10 |
11 | func (r *CategoryRepository) Add(c *model.Category) error {
12 | return r.Repository.db.QueryRow(
13 | "INSERT INTO categories (category) VALUES ( ? ) RETURNING id",
14 | c.Name,
15 | ).Scan(&c.ID)
16 | }
17 |
18 | func (r *CategoryRepository) GetAll() ([]*model.Category, error) {
19 | var categories []*model.Category
20 | rows, err := r.Repository.db.Query(
21 | "SELECT id, category FROM categories ORDER BY category ASC",
22 | )
23 | if err != nil {
24 | return nil, err
25 | }
26 | defer rows.Close()
27 | for rows.Next() {
28 | category := &model.Category{}
29 | err = rows.Scan(&category.ID, &category.Name)
30 | if err != nil {
31 | return nil, err
32 | }
33 | categories = append(categories, category)
34 | }
35 | if len(categories) == 0 {
36 | return []*model.Category{}, nil
37 | }
38 | return categories, nil
39 | }
40 |
41 | func (r *CategoryRepository) GetCategoryById(Id int) (*model.Category, error) {
42 | row := r.Repository.db.QueryRow("SELECT * FROM categories WHERE id = ?", Id)
43 | category := &model.Category{}
44 | if err := row.Scan(&category.ID, &category.Name); err != nil {
45 | return nil, err
46 | }
47 | return category, nil
48 | }
49 |
50 | func (r *CategoryRepository) GetCategoryByName(categoryName string) (*model.Category, error) {
51 | row := r.Repository.db.QueryRow("SELECT * FROM categories WHERE category = ?", categoryName)
52 | category := &model.Category{}
53 | if err := row.Scan(&category.ID, &category.Name); err != nil {
54 | return nil, err
55 | }
56 | return category, nil
57 | }
58 |
--------------------------------------------------------------------------------
/frontend/public/icons/messages.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/frontend/components/group-comment.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Comment } from "@/types/comment";
4 | import Image from "next/image";
5 |
6 | interface CommentProps {
7 | comment: Comment;
8 | postID: number;
9 | groupID?: number;
10 | }
11 |
12 | export default function GroupComment({ comment, postID, groupID }: CommentProps) {
13 | return (
14 |
15 |
16 |
30 |
31 |
32 |
33 | {comment.author}
34 | {comment.text && {comment.text}}
35 |
36 |
37 | {comment.image && (
38 |
39 |
53 |
54 | )}
55 |
56 |
{comment.creation_date}
57 |
58 |
59 | );
60 | }
61 |
--------------------------------------------------------------------------------
/frontend/components/comment.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Comment as CommentType } from "@/types/comment";
4 | import Image from "next/image";
5 |
6 | interface CommentProps {
7 | comment: CommentType;
8 | postID: number;
9 | }
10 |
11 | export default function Comment({ comment, postID }: CommentProps) {
12 | console.log("COMMENT DATA ----------------------", comment);
13 | return (
14 |
15 |
16 |
30 |
31 |
32 |
33 | {comment.author}
34 | {comment.text && {comment.text}}
35 |
36 |
37 | {comment.image && (
38 |
39 |
53 |
54 | )}
55 |
56 |
{comment.creation_date}
57 |
58 |
59 | );
60 | }
61 |
--------------------------------------------------------------------------------
/backend/pkg/controller/image.go:
--------------------------------------------------------------------------------
1 | package app
2 |
3 | import (
4 | "io"
5 | "net/http"
6 | "os"
7 | "path/filepath"
8 | "social-network/pkg/model/response"
9 | "strings"
10 | )
11 |
12 | func (app *App) UploadImage(w http.ResponseWriter, r *http.Request) {
13 |
14 | const maxBytes = 10 * 1024 * 1024
15 |
16 | err := r.ParseMultipartForm(maxBytes)
17 | if err != nil {
18 | app.ServeError(w, &response.Error{Cause: err.Error(), Code: 400})
19 | return
20 | }
21 |
22 | targetPath := r.FormValue("path")
23 | fileName := r.FormValue("filename")
24 | if targetPath == "" || fileName == "" {
25 | app.ServeError(w, &response.Error{Cause: "Invalid path or filename", Code: 400})
26 | return
27 | }
28 |
29 | file, fileHeader, err := r.FormFile("image")
30 | if err != nil {
31 | app.ServeError(w, &response.Error{Cause: err.Error(), Code: 400})
32 | return
33 | }
34 | defer file.Close()
35 |
36 | if fileHeader.Size > maxBytes {
37 | app.ServeError(w, &response.Error{Cause: "You exeeded the allowed size", Code: 400})
38 | return
39 | }
40 |
41 | // Extension check
42 | ext := strings.ToLower(filepath.Ext(fileName))
43 | allowed := map[string]bool{
44 | ".jpg": true,
45 | ".jpeg": true,
46 | ".png": true,
47 | ".gif": true,
48 | }
49 | if !allowed[ext] {
50 | app.ServeError(w, &response.Error{Cause: "Only .jpg, .jpeg, .png, .gif files are allowed", Code: 400})
51 | return
52 | }
53 |
54 | // Create directory if it doesn't exist
55 | dirPath := filepath.Join("./static", targetPath)
56 | err = os.MkdirAll(dirPath, os.ModePerm)
57 | if err != nil {
58 | app.ServeError(w, &response.Error{Cause: err.Error(), Code: 500})
59 | return
60 | }
61 |
62 | destPath := filepath.Join(dirPath, fileName)
63 |
64 | dst, err := os.Create(destPath)
65 | if err != nil {
66 | app.ServeError(w, &response.Error{Cause: err.Error(), Code: 500})
67 | return
68 | }
69 | defer dst.Close()
70 |
71 | _, err = io.Copy(dst, file)
72 | if err != nil {
73 |
74 | app.ServeError(w, &response.Error{Cause: err.Error(), Code: 500})
75 | return
76 | }
77 |
78 | w.WriteHeader(200)
79 | w.Write([]byte("File saved successfully"))
80 | }
81 |
--------------------------------------------------------------------------------
/backend/pkg/repository/commentRepo.go:
--------------------------------------------------------------------------------
1 | package repository
2 |
3 | import (
4 | "social-network/pkg/model"
5 | "time"
6 | )
7 |
8 | type CommentRepository struct {
9 | Repository *Repository
10 | }
11 |
12 | func (r *CommentRepository) Add(c *model.Comment) error {
13 | return r.Repository.db.QueryRow(
14 | "INSERT INTO comments (user_id, post_id, text, image) VALUES ($1, $2, $3, $4) RETURNING id",
15 | c.UserId, c.PostId, c.Text, c.Image,
16 | ).Scan(&c.ID)
17 | }
18 |
19 | func (r *CommentRepository) GetCommentsByPostId(Id int) ([]*model.Comment, error) {
20 | var comments []*model.Comment
21 |
22 | // Updated query to use firstname and lastname instead of username
23 | query := `
24 | SELECT
25 | comments.id,
26 | comments.user_id,
27 | users.avatar,
28 | COALESCE(users.firstname || ' ' || users.lastname, '') AS author,
29 | comments.post_id,
30 | comments.text,
31 | comments.image,
32 | comments.creation_date
33 | FROM comments
34 | JOIN users ON users.id = comments.user_id
35 | WHERE post_id = $1
36 | ORDER BY comments.creation_date DESC;
37 | `
38 |
39 | rows, err := r.Repository.db.Query(query, Id)
40 |
41 | if err != nil {
42 | return nil, err
43 | }
44 |
45 | defer rows.Close()
46 |
47 | for rows.Next() {
48 | comment := &model.Comment{}
49 | if err := rows.Scan(&comment.ID, &comment.UserId, &comment.UserAvatar, &comment.Author, &comment.PostId, &comment.Text, &comment.Image, &comment.CreationDate); err != nil {
50 | return nil, err
51 | }
52 | newTime, _ := time.Parse("2006-01-02T15:04:05Z", comment.CreationDate)
53 | comment.CreationDate = newTime.Format("2006-01-02 15:04:05")
54 | comment.PostId = Id
55 | comments = append(comments, comment)
56 | }
57 |
58 | if len(comments) == 0 {
59 | return []*model.Comment{}, nil
60 | }
61 | return comments, nil
62 | }
63 |
64 | func (r *CommentRepository) GetCommentCountByPostId(postId int) (int, error) {
65 | var count int
66 | err := r.Repository.db.QueryRow(
67 | `SELECT COUNT(*) FROM comments WHERE post_id = $1`, postId,
68 | ).Scan(&count)
69 | if err != nil {
70 | return 0, err
71 | }
72 | return count, nil
73 | }
74 |
--------------------------------------------------------------------------------
/frontend/helpers/uploadFile.ts:
--------------------------------------------------------------------------------
1 | import { showGlobalError } from "@/helpers/ErrorProvider";
2 |
3 | export async function uploadFile(
4 | formData: FormData,
5 | route: string
6 | ): Promise {
7 | try {
8 | const file = formData.get("file") as File;
9 |
10 | if (!file) {
11 | const msg = "No file provided";
12 | showGlobalError(msg);
13 | throw new Error(msg);
14 | }
15 |
16 | if (!file.type.startsWith("image/")) {
17 | const msg = "Only image files are allowed (by MIME type)";
18 | showGlobalError(msg);
19 | throw new Error(msg);
20 | }
21 |
22 | const allowedExtensions = ["jpg", "jpeg", "png", "gif"];
23 | const fileExtension = file.name.split(".").pop()?.toLowerCase();
24 |
25 | if (!fileExtension || !allowedExtensions.includes(fileExtension)) {
26 | const msg = "Only .jpg, .jpeg, .png, .gif images are allowed";
27 | showGlobalError(msg);
28 | throw new Error(msg);
29 | }
30 |
31 | const maxSizeInBytes = 10 * 1024 * 1024;
32 | if (file.size > maxSizeInBytes) {
33 | const msg = "File size must be ≤ 10MB";
34 | showGlobalError(msg);
35 | throw new Error(msg);
36 | }
37 |
38 | const uuid = crypto.randomUUID();
39 | const uniqueFileName = `${uuid}_${file.name
40 | .replace(/\s+/g, "-")
41 | .toLowerCase()}`;
42 |
43 | formData.append("image", file);
44 | formData.append("path", route);
45 | formData.append("filename", uniqueFileName);
46 |
47 | const response = await fetch("http://localhost:8080/uploadImage", {
48 | method: "POST",
49 | body: formData,
50 | });
51 |
52 | if (response.ok) {
53 | return uniqueFileName;
54 | }
55 |
56 | const data = await response.json();
57 |
58 | if (data.error) {
59 | const msg = data.error;
60 | showGlobalError(msg);
61 | throw new Error(msg);
62 | }
63 |
64 | return "";
65 | } catch (error: any) {
66 | const msg = error?.message || "Failed to upload file";
67 | showGlobalError(msg);
68 | console.error("Upload Error:", error);
69 | throw new Error("Failed to upload file: " + msg);
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/backend/pkg/controller/profile.go:
--------------------------------------------------------------------------------
1 | package app
2 |
3 | import (
4 | "social-network/pkg/model"
5 | "social-network/pkg/model/request"
6 | "social-network/pkg/model/response"
7 | )
8 |
9 | func (app *App) GetProfile(payload *request.RequestT) any {
10 | data, ok := payload.Data.(*request.GetProfile)
11 | if !ok {
12 | return &response.Error{Code: 400, Cause: "Invalid payload type"}
13 | }
14 |
15 | userId := payload.Ctx.Value("user_id").(int)
16 |
17 | currentUser, err := app.repository.User().FindProfile(userId, userId)
18 | if err != nil {
19 | return &response.Error{Code: 404, Cause: err.Error()}
20 | }
21 | user, err := app.repository.User().FindProfile(data.ProfileId, userId)
22 | if err != nil {
23 | return &response.Error{Code: 404, Cause: err.Error()}
24 | }
25 |
26 | return &response.GetProfile{
27 | User: user,
28 | CurrentUser: currentUser,
29 | }
30 | }
31 |
32 | func (app *App) GetProfiles(payload *request.RequestT) any {
33 | userId := payload.Ctx.Value("user_id").(int)
34 |
35 | followRequests, err := app.repository.Follow().GetFollowRequests(userId)
36 | if err != nil {
37 | return &response.Error{Code: 500, Cause: err.Error()}
38 | }
39 |
40 | allUsers, err := app.repository.User().GetAllUsers()
41 | if err != nil {
42 | return &response.Error{Code: 500, Cause: err.Error()}
43 | }
44 |
45 | var otherUsers []*model.User
46 | var currentUser *model.User
47 | for _, user := range allUsers {
48 | if user.ID != userId {
49 | otherUsers = append(otherUsers, user)
50 | } else {
51 | currentUser = user
52 | }
53 | }
54 |
55 | return &response.GetProfiles{
56 | FollowRequests: followRequests,
57 | AllUsers: otherUsers,
58 | CurrentUser: currentUser,
59 | }
60 | }
61 |
62 | func (app *App) SetProfilePrivacy(payload *request.RequestT) any {
63 | data, ok := payload.Data.(*request.SetProfilePrivacy)
64 | if !ok {
65 | return &response.Error{Code: 400, Cause: "Invalid payload type"}
66 | }
67 |
68 | userId := payload.Ctx.Value("user_id").(int)
69 |
70 | err := app.repository.User().SetUserPrivacy(userId, data.State)
71 | if err != nil {
72 | return &response.Error{Code: 500, Cause: err.Error()}
73 | }
74 |
75 | return &response.SetProfilePrivacy{
76 | Success: true,
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/frontend/helpers/GlobalAPIHelper.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { showGlobalError } from "@/helpers/ErrorProvider";
4 | import { useRouter } from "next/navigation";
5 | import { useCallback } from "react";
6 | import { closeWebSocket } from "./webSocket";
7 | import setSessionCookie from "@/api/auth/setSession";
8 |
9 | export const useGlobalAPIHelper = () => {
10 | const router = useRouter();
11 |
12 | const handleAPIError = (message: string, code = 500) => {
13 | showGlobalError(message, code);
14 |
15 | if (
16 | message.toLowerCase().startsWith("unauthorized: invalid session") &&
17 | router
18 | ) {
19 | closeWebSocket();
20 | setTimeout(() => {
21 | router.push("/auth");
22 | }, 1000); // Delay allows popup to show
23 | }
24 |
25 | console.log(`API Error [${code}]: ${message}`);
26 | return { error: true, message };
27 | };
28 |
29 | const apiCall = useCallback(
30 | async (requestData: any, method: string, url: string): Promise => {
31 | try {
32 | const response = await fetch(`http://localhost:8080/${url}`, {
33 | method,
34 | headers: {
35 | "Content-Type": "application/json",
36 | },
37 | body: JSON.stringify(requestData),
38 | credentials: "include",
39 | });
40 |
41 | const data = await response.json();
42 |
43 | if (
44 | (url === "login" || url === "register") &&
45 | data.data?.session &&
46 | !data.error
47 | ) {
48 | await setSessionCookie(data.data?.session);
49 | data.data.session = "true";
50 | }
51 |
52 | if (!response.ok) {
53 | const message =
54 | data?.error?.cause || data?.message || "Unknown error from server";
55 | const code = data?.error?.code || response.status;
56 | return handleAPIError(message, code);
57 | }
58 |
59 | if (data.error) {
60 | const message = data.error.cause || "Unknown error";
61 | const code = data.error.code || 500;
62 | return handleAPIError(message, code);
63 | }
64 |
65 | return data.data ?? data;
66 | } catch (err: any) {
67 | console.error("API call failed:", err);
68 | return handleAPIError(err.message || "Unexpected error", 500);
69 | }
70 | },
71 | []
72 | );
73 |
74 | return { apiCall };
75 | };
76 |
--------------------------------------------------------------------------------
/frontend/public/icons/broken-heart.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/frontend/helpers/webSocket.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import getToken from "@/api/auth/getToken";
4 | import { showGlobalError } from "@/helpers/ErrorProvider";
5 |
6 | export let socket: WebSocket | null = null;
7 |
8 | let listeners: { [key: string]: ((data: any) => void)[] } = ({} = {});
9 |
10 | export const connectWebSocket = async (): Promise => {
11 | if (socket) return socket;
12 |
13 | try {
14 | const token = await getToken();
15 | socket = new WebSocket(`ws://localhost:8080/ws?session=${token}`);
16 |
17 | socket.onopen = () => {
18 | console.log("✅ WebSocket connected");
19 | };
20 |
21 | socket.onmessage = (event) => {
22 | try {
23 | const data = JSON.parse(event.data);
24 | console.log("WebSocket message received:", data);
25 |
26 | if (data.error) {
27 | showGlobalError(
28 | data.error.cause || "Unknown WebSocket error",
29 | data.error.code || 500
30 | );
31 | console.warn("WebSocket error:", data.error);
32 | return;
33 | }
34 |
35 | const type = data.data?.type || data?.type;
36 |
37 | if (type && listeners[type]) {
38 | listeners[type].forEach((callback) => callback(data.data));
39 | }
40 | } catch (error) {
41 | console.error("Error parsing WebSocket message:", error);
42 | }
43 | };
44 |
45 | socket.onerror = (err) => {
46 | console.error("WebSocket error:", err);
47 | };
48 |
49 | return socket;
50 | } catch (error) {
51 | return null;
52 | }
53 | };
54 |
55 | export const closeWebSocket = (): void => {
56 | if (socket && socket.readyState === WebSocket.OPEN) {
57 | socket.close();
58 | socket = null;
59 | listeners = {} = {};
60 | console.log("🔌 WebSocket connection closed");
61 | }
62 | };
63 |
64 | // Register a callback for a given message type
65 | export const onMessageType = (
66 | type: string,
67 | callback: (data: any) => void
68 | ): (() => void) => {
69 | if (!listeners[type]) {
70 | listeners[type] = [];
71 | }
72 |
73 | listeners[type].push(callback);
74 |
75 | // Return an unsubscribe function
76 | return () => {
77 | listeners[type] = listeners[type]?.filter((cb) => cb !== callback);
78 |
79 | // Clean up if no more listeners
80 | if (listeners[type]?.length === 0) {
81 | delete listeners[type];
82 | }
83 | };
84 | };
85 |
--------------------------------------------------------------------------------
/backend/pkg/controller/groupReaction.go:
--------------------------------------------------------------------------------
1 | package app
2 |
3 | import (
4 | "log"
5 | "social-network/pkg/model"
6 | "social-network/pkg/model/request"
7 | "social-network/pkg/model/response"
8 | )
9 |
10 | func (app *App) ReactToGroupPost(payload *request.RequestT) any {
11 | data, ok := payload.Data.(*request.ReactToGroupPost)
12 | if !ok {
13 | return &response.Error{Code: 400, Cause: "Invalid payload type"}
14 | }
15 |
16 | userId := payload.Ctx.Value("user_id").(int)
17 |
18 | isMember, err := app.repository.Group().IsGroupPostMember(userId, data.PostId)
19 | if err != nil || !isMember {
20 | return &response.Error{Code: 403, Cause: "You are not a member of this group"}
21 | }
22 |
23 | err = app.repository.Group().UpsertGroupReaction(userId, data.PostId, data.Reaction)
24 | if err != nil {
25 | log.Println("Error saving group reaction:", err)
26 | return &response.Error{Code: 500, Cause: "Error saving reaction: " + err.Error()}
27 | }
28 |
29 | counts, err := app.repository.Group().GetGroupReactionCounts(data.PostId)
30 | if err != nil {
31 | log.Println("Error getting group reaction counts:", err)
32 | return &response.Error{Code: 500, Cause: "Error getting reaction counts: " + err.Error()}
33 | }
34 |
35 | userReaction, err := app.repository.Group().GetGroupUserReaction(userId, data.PostId)
36 | if err != nil {
37 | log.Println("Error getting group user reaction:", err)
38 | return &response.Error{Code: 500, Cause: "Error getting user reaction: " + err.Error()}
39 | }
40 | counts.UserReaction = userReaction
41 |
42 | post := &model.Post{
43 | ID: data.PostId,
44 | Reactions: counts,
45 | }
46 |
47 | return &response.ReactToGroupPost{
48 | Post: post,
49 | }
50 | }
51 |
52 | func (app *App) GetGroupPost(payload *request.RequestT) any {
53 | data, ok := payload.Data.(*request.GetGroupPost)
54 | if !ok {
55 | return &response.Error{Code: 400, Cause: "Invalid payload type"}
56 | }
57 | userId := payload.Ctx.Value("user_id").(int)
58 |
59 | isMember, err := app.repository.Group().IsGroupPostMember(userId, data.PostId)
60 | if err != nil || !isMember {
61 | return &response.Error{Code: 403, Cause: "You are not a member of this group"}
62 | }
63 |
64 | post, err := app.repository.Group().GetGroupPostById(userId, data.PostId)
65 | if err != nil {
66 | log.Println("Error getting group post data:", err)
67 | return &response.Error{Code: 500, Cause: "Error getting post data: " + err.Error()}
68 | }
69 |
70 | return &response.GetGroupPost{
71 | Post: post,
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/frontend/public/icons/users.svg:
--------------------------------------------------------------------------------
1 |
2 |
37 |
--------------------------------------------------------------------------------
/backend/go.sum:
--------------------------------------------------------------------------------
1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
4 | github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs=
5 | github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY=
6 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
7 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
8 | github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
9 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
10 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
11 | github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
12 | github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
13 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
14 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
15 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
16 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
17 | github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
18 | github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
19 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
20 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
21 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
22 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
23 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
24 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
25 | go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
26 | go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
27 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
28 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
29 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
30 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
31 |
--------------------------------------------------------------------------------
/backend/pkg/controller/comment.go:
--------------------------------------------------------------------------------
1 | package app
2 |
3 | import (
4 | "log"
5 | "social-network/pkg/model"
6 | "social-network/pkg/model/request"
7 | "social-network/pkg/model/response"
8 | )
9 |
10 | func (app *App) AddComment(payload *request.RequestT) any {
11 | data, ok := payload.Data.(*request.AddComment)
12 | if !ok {
13 | return &response.Error{Code: 400, Cause: "Invalid payload type"}
14 | }
15 |
16 | userId := payload.Ctx.Value("user_id").(int)
17 |
18 | if data.Text == "" && data.Image == "" {
19 | return &response.Error{Code: 400, Cause: "Comment text cannot be empty"}
20 | }
21 | if len(data.Text) > 500 {
22 | return &response.Error{Code: 400, Cause: "Comment text exceeds maximum length of 500 characters"}
23 | }
24 |
25 | user, err := app.repository.User().Find(userId)
26 | if err != nil {
27 | return &response.Error{Code: 500, Cause: "Error finding user"}
28 | }
29 |
30 | comment := &model.Comment{
31 | UserId: userId,
32 | PostId: data.PostId,
33 | Text: data.Text,
34 | Image: data.Image,
35 | Author: user.Username,
36 | }
37 |
38 | err = app.repository.Comment().Add(comment)
39 | if err != nil {
40 | log.Println("Error adding comment:", err)
41 | return &response.Error{Code: 500, Cause: "Error adding comment: " + err.Error()}
42 | }
43 |
44 | comments, err := app.repository.Comment().GetCommentsByPostId(data.PostId)
45 | if err != nil {
46 | log.Println("Error getting comments:", err)
47 | return &response.Error{Code: 500, Cause: "Error getting comments: " + err.Error()}
48 | }
49 |
50 | post, err := app.repository.Post().GetPostById(userId, data.PostId)
51 | if err != nil {
52 | log.Println("Error getting post data:", err)
53 | return &response.Error{Code: 500, Cause: "Error getting post data: " + err.Error()}
54 | }
55 |
56 | post.Comment = comments
57 | return post
58 | }
59 |
60 | func (app *App) GetComments(payload *request.RequestT) any {
61 | data, ok := payload.Data.(*request.GetComments)
62 | if !ok {
63 | return &response.Error{Code: 400, Cause: "Invalid payload type"}
64 | }
65 |
66 | userId := payload.Ctx.Value("user_id").(int)
67 |
68 | comments, err := app.repository.Comment().GetCommentsByPostId(data.PostId)
69 | if err != nil {
70 | log.Println("Error getting comments:", err)
71 | return &response.Error{Code: 500, Cause: "Error getting comments: " + err.Error()}
72 | }
73 |
74 | post, err := app.repository.Post().GetPostById(userId, data.PostId)
75 | if err != nil {
76 | log.Println("Error getting post data:", err)
77 | return &response.Error{Code: 500, Cause: "Error getting post data: " + err.Error()}
78 | }
79 |
80 | post.Comment = comments
81 | return post
82 | }
83 |
--------------------------------------------------------------------------------
/backend/pkg/controller/chat.go:
--------------------------------------------------------------------------------
1 | package app
2 |
3 | import (
4 | "social-network/pkg/model"
5 | "social-network/pkg/model/request"
6 | "social-network/pkg/model/response"
7 | )
8 |
9 | func (app *App) GetChat(payload *request.RequestT) any {
10 | userId := payload.Ctx.Value("user_id").(int)
11 |
12 | privateConvs, err := app.repository.Message().GetPrivateConversations(userId)
13 | if err != nil {
14 | return &response.Error{Code: 500, Cause: err.Error()}
15 | }
16 |
17 | groupConvs, err := app.repository.Message().GetGroupConversations(userId)
18 | if err != nil {
19 | return &response.Error{Code: 500, Cause: err.Error()}
20 | }
21 |
22 | newConvs, err := app.repository.Message().GetNewConversations(userId)
23 | if err != nil {
24 | return &response.Error{Code: 500, Cause: err.Error()}
25 | }
26 |
27 | return &response.GetChat{
28 | PrivateConvs: privateConvs,
29 | GroupConvs: groupConvs,
30 | NewConvs: newConvs,
31 | }
32 | }
33 |
34 | func (app *App) GetMessages(payload *request.RequestT) any {
35 | data, ok := payload.Data.(*request.GetMessages)
36 | if !ok {
37 | return &response.Error{Code: 400, Cause: "Invalid payload type"}
38 | }
39 | userId := payload.Ctx.Value("user_id").(int)
40 |
41 | m := &model.Message{}
42 | if data.IsGroup {
43 | m.GroupId = data.Id
44 | } else {
45 | m.RecipientId = data.Id
46 | }
47 | m.SenderId = userId
48 |
49 | messages, err := app.repository.Message().GetMessages(m)
50 | if err != nil {
51 | return &response.Error{Code: 500, Cause: err.Error()}
52 | }
53 |
54 | app.UpdateSeenMessage(data.IsGroup, userId, data.Id)
55 | wsMsg := map[string]any{
56 | "type": "unreadmsgRequestHandled",
57 | }
58 | for _, c := range app.clients[userId] {
59 | app.ShowMessage(c, wsMsg)
60 | }
61 |
62 | return &response.GetMessages{
63 | Messages: messages,
64 | }
65 | }
66 |
67 | func (app *App) AddMessage(payload *request.RequestT) (*response.AddMessage, *response.Error) {
68 | data, ok := payload.Data.(*request.AddMessage)
69 | if !ok {
70 | return nil, &response.Error{Code: 400, Cause: "Invalid payload type"}
71 | }
72 | userId := payload.Ctx.Value("user_id").(int)
73 |
74 | if len(data.Message) == 0 || len(data.Message) > 1000 || data.Id < 1 {
75 | return nil, &response.Error{Code: 400, Cause: "Invalid message input"}
76 | }
77 |
78 | m := &model.Message{}
79 | if data.IsGroup {
80 | m.GroupId = data.Id
81 | } else {
82 | m.RecipientId = data.Id
83 | }
84 | m.SenderId = userId
85 | m.Text = data.Message
86 |
87 | // Should add middleware to validate if user can send message
88 |
89 | err := app.repository.Message().Add(m)
90 | if err != nil {
91 | return nil, &response.Error{Code: 500, Cause: err.Error()}
92 | }
93 |
94 | err = app.repository.Message().AddGroupMessageNotifications(m)
95 | if err != nil {
96 | return nil, &response.Error{Code: 500, Cause: err.Error()}
97 | }
98 |
99 | return &response.AddMessage{
100 | Type: "addMessage",
101 | Message: m,
102 | IsGroup: data.IsGroup,
103 | }, nil
104 | }
105 |
--------------------------------------------------------------------------------
/backend/pkg/server/start.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "log"
5 | "net/http"
6 | "social-network/pkg/db/sqlite"
7 | )
8 |
9 | func Start() error {
10 | db := sqlite.InitDB("pkg/db/forum.db")
11 |
12 | defer db.Close()
13 |
14 | s := NewServer(http.NewServeMux(), db)
15 |
16 | // Auth
17 | s.AddRoute("/login", s.app.Login)
18 | s.AddRoute("/register", s.app.Register)
19 | s.AddRoute("/logout", s.app.Logout)
20 |
21 | // Posts
22 | s.AddRoute("/getPosts", s.app.GetPosts)
23 | s.AddRoute("/getPost", s.app.GetPostData)
24 | s.AddRoute("/addPost", s.app.AddPost)
25 | s.AddRoute("/reactToPost", s.app.ReactToPost)
26 |
27 | // Post Shares
28 | s.AddRoute("/getPostShares", s.app.GetPostShares)
29 | s.AddRoute("/addPostShare", s.app.AddPostShare)
30 | s.AddRoute("/removePostShare", s.app.RemovePostShare)
31 |
32 | // Comments
33 | s.AddRoute("/addComment", s.app.AddComment)
34 | s.AddRoute("/getComments", s.app.GetComments)
35 |
36 | // Profiles
37 | s.AddRoute("/getProfiles", s.app.GetProfiles)
38 | s.AddRoute("/getProfile", s.app.GetProfile)
39 | s.AddRoute("/setPrivacy", s.app.SetProfilePrivacy)
40 |
41 | // Follows
42 | s.AddRoute("/requestFollow", s.app.RequestFollow)
43 | s.AddRoute("/acceptFollow", s.app.AcceptFollow)
44 | s.AddRoute("/deleteFollow", s.app.DeleteFollow)
45 |
46 | // Groups
47 | s.AddRoute("/createGroup", s.app.CreateGroup)
48 | s.AddRoute("/getGroups", s.app.GetGroups)
49 | s.AddRoute("/getGroup", s.app.GetGroupData)
50 |
51 | // Group post reactions and comments
52 | s.AddRoute("/reactToGroupPost", s.app.ReactToGroupPost, s.app.IsMemberMiddleware)
53 | s.AddRoute("/addGroupComment", s.app.AddGroupComment, s.app.IsMemberMiddleware)
54 | s.AddRoute("/getGroupComments", s.app.GetGroupComments, s.app.IsMemberMiddleware)
55 |
56 | s.AddRoute("/addGroupPost", s.app.AddGroupPost, s.app.IsMemberMiddleware)
57 | s.AddRoute("/addGroupEvent", s.app.AddGroupEvent, s.app.IsMemberMiddleware)
58 | s.AddRoute("/addEventOption", s.app.AddEventOption, s.app.IsMemberMiddleware)
59 | s.AddRoute("/requestJoinGroup", s.app.RequestJoinGroup)
60 | s.AddRoute("/respondToJoinRequest", s.app.RespondToJoinRequest)
61 |
62 | // Group Invitation Routes
63 | s.AddRoute("/getGroupInviteUsers", s.app.GetGroupInviteUsers, s.app.IsMemberMiddleware)
64 | s.AddRoute("/inviteUserToGroup", s.app.InviteUserToGroup, s.app.IsMemberMiddleware)
65 | s.AddRoute("/respondToGroupInvitation", s.app.RespondToGroupInvitation)
66 |
67 | // Chat
68 | s.AddRoute("/getChatData", s.app.GetChat)
69 | s.AddRoute("/getMessages", s.app.GetMessages)
70 |
71 | //notif
72 | s.AddRoute("/getAllNotifications", s.app.GetAllNotifications)
73 | s.AddRoute("/getNewFollowNotification", s.app.CheckNewFollowNotification)
74 | s.AddRoute("/deleteFollowNotif", s.app.DeleteFollowNotification)
75 | s.AddRoute("/deleteNotifNewEvent", s.app.DeleteNewEventNotification)
76 |
77 | // ws
78 | s.router.HandleFunc("/ws", s.app.WebSocketHandler)
79 |
80 | // Image
81 | s.router.HandleFunc("/uploadImage", s.app.UploadImage)
82 | s.router.Handle("/getProtectedImage", s.app.ImageMiddleware(http.HandlerFunc(s.app.ProtectedImageHandler)))
83 |
84 | log.Println("Server started at http://localhost:8080/")
85 | return http.ListenAndServe(":8080", s.app.CorsMiddleware(s))
86 | }
87 |
--------------------------------------------------------------------------------
/backend/pkg/controller/follow.go:
--------------------------------------------------------------------------------
1 | package app
2 |
3 | import (
4 | "log"
5 | "social-network/pkg/model/request"
6 | "social-network/pkg/model/response"
7 | "time"
8 | )
9 |
10 | func (app *App) RequestFollow(payload *request.RequestT) any {
11 | data, ok := payload.Data.(*request.RequestFollow)
12 | if !ok {
13 | return &response.Error{Code: 400, Cause: "Invalid payload type"}
14 | }
15 | userId := payload.Ctx.Value("user_id").(int)
16 |
17 | err := app.repository.Follow().RequestFollow(data.ProfileId, userId)
18 | if err != nil {
19 | log.Println("Error requesting follow:", err)
20 | return &response.Error{Code: 500, Cause: err.Error()}
21 | }
22 | notification := map[string]any{
23 | "type": "notifications",
24 | "followerId": userId,
25 | "message": "New follow request",
26 | "timestamp": time.Now().Unix(),
27 | }
28 | app.sendNotificationToUser(data.ProfileId, notification)
29 |
30 | return &response.RequestFollow{
31 | Success: true,
32 | Message: "Follow request sent",
33 | }
34 | }
35 |
36 | func (app *App) AcceptFollow(payload *request.RequestT) any {
37 | data, ok := payload.Data.(*request.AcceptFollow)
38 | if !ok {
39 | return &response.Error{Code: 400, Cause: "Invalid payload type"}
40 | }
41 | userId := payload.Ctx.Value("user_id").(int)
42 |
43 | err := app.repository.Follow().AcceptFollow(data.ProfileId, userId)
44 | if err != nil {
45 | log.Println("Error accepting follow:", err)
46 | return &response.Error{Code: 500, Cause: err.Error()}
47 | }
48 | wsMsg := map[string]any{
49 | "type": "followRequestHandled",
50 | }
51 | for _, c := range app.clients[userId] {
52 | app.ShowMessage(c, wsMsg)
53 | }
54 | return &response.AcceptFollow{
55 | Success: true,
56 | Message: "Follow request accepted",
57 | }
58 | }
59 |
60 | func (app *App) DeleteFollow(payload *request.RequestT) any {
61 | data, ok := payload.Data.(*request.DeleteFollow)
62 | if !ok {
63 | return &response.Error{Code: 400, Cause: "Invalid payload type"}
64 | }
65 | userId := payload.Ctx.Value("user_id").(int)
66 |
67 | var err error
68 | if data.IsFollower {
69 | err = app.repository.Follow().DeleteFollow(data.ProfileId, userId)
70 | if err == nil {
71 | err = app.repository.Follow().DeleteNotif(userId, data.ProfileId)
72 | }
73 | notification := map[string]any{
74 | "type": "notifications",
75 | "followerId": userId,
76 | "message": "unfollow",
77 | "timestamp": time.Now().Unix(),
78 | }
79 | app.sendNotificationToUser(data.ProfileId, notification)
80 | } else {
81 | err = app.repository.Follow().DeleteFollow(userId, data.ProfileId)
82 | notification := map[string]any{
83 | "type": "notifications",
84 | "followerId": data.ProfileId,
85 | "message": "unfollow",
86 | "timestamp": time.Now().Unix(),
87 | }
88 | app.sendNotificationToUser(userId, notification)
89 | }
90 | if err != nil {
91 | log.Println("Error deleting follow:", err)
92 | return &response.Error{Code: 500, Cause: err.Error()}
93 | }
94 |
95 | return &response.DeleteFollow{
96 | Success: true,
97 | Message: "Unfollowed successfully",
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/backend/pkg/controller/post.go:
--------------------------------------------------------------------------------
1 | package app
2 |
3 | import (
4 | "database/sql"
5 | "fmt"
6 | "log"
7 | "social-network/pkg/model"
8 | "social-network/pkg/model/request"
9 | "social-network/pkg/model/response"
10 | )
11 |
12 | func (app *App) GetPosts(payload *request.RequestT) any {
13 | data, ok := payload.Data.(*request.GetPosts)
14 | if !ok {
15 | return &response.Error{Code: 400, Cause: "Invalid payload type"}
16 | }
17 |
18 | userId := payload.Ctx.Value("user_id").(int)
19 |
20 | posts, err := app.repository.Post().GetPosts(userId, data.StartId)
21 | if err != nil {
22 | log.Println("Error in getting feed data:", err)
23 | return &response.Error{Code: 400, Cause: "Error in getting feed data"}
24 | }
25 |
26 | return &response.GetPosts{
27 | Posts: posts,
28 | Userid: userId,
29 | }
30 | }
31 |
32 | func (app *App) GetPostData(payload *request.RequestT) any {
33 | data, ok := payload.Data.(*request.GetPost)
34 | if !ok {
35 | return &response.Error{Code: 400, Cause: "Invalid payload type"}
36 | }
37 |
38 | userId := payload.Ctx.Value("user_id").(int)
39 | post, err := app.repository.Post().GetPostById(userId, data.PostId)
40 |
41 | if err != nil {
42 | if err == sql.ErrNoRows {
43 | return &response.Error{Code: 404, Cause: "Post not found"}
44 | }
45 | return &response.Error{Code: 400, Cause: "Error in getting post data"}
46 | }
47 |
48 | count, err := app.repository.Comment().GetCommentCountByPostId(data.PostId)
49 | if err != nil {
50 | log.Println("Error getting comment count:", err)
51 | }
52 | post.CommentCount = count
53 | fmt.Println("post", post)
54 |
55 | return &response.GetPost{
56 | Userid: userId,
57 | Post: post,
58 | }
59 | }
60 |
61 | func (app *App) AddPost(payload *request.RequestT) any {
62 | fmt.Println(payload)
63 | data, ok := payload.Data.(*request.AddPost)
64 | if !ok {
65 | return &response.Error{Code: 400, Cause: "Invalid payload type"}
66 | }
67 |
68 | if data.Caption == "" && data.Image == "" {
69 | return &response.Error{Code: 400, Cause: "Can't create empty posts"}
70 | }
71 |
72 | if data.Privacy != "public" && data.Privacy != "almost-private" && data.Privacy != "private" {
73 | return &response.Error{Code: 400, Cause: "Invalid privacy type"}
74 | }
75 |
76 | user, err := app.repository.User().Find(payload.Ctx.Value("user_id").(int))
77 | if err != nil {
78 | return &response.Error{Code: 500, Cause: "An error has aquired while finding user"}
79 | }
80 |
81 | post := &model.Post{
82 | UserId: user.ID,
83 | Caption: data.Caption,
84 | Privacy: data.Privacy,
85 | Image: data.Image,
86 | User: &model.User{
87 | Firstname: user.Firstname,
88 | Lastname: user.Lastname,
89 | Avatar: user.Avatar,
90 | },
91 | }
92 |
93 | if len(post.Caption) > 1000 {
94 | return &response.Error{Code: 400, Cause: "Caption exceeds maximum allowed length"}
95 | }
96 |
97 | err = app.repository.Post().Add(post)
98 | if err != nil {
99 | log.Println("Error adding post:", err)
100 | return &response.Error{Code: 500, Cause: "Error adding post: " + err.Error()}
101 | }
102 |
103 | return &response.AddPost{
104 | Post: post,
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/backend/pkg/model/request/request.go:
--------------------------------------------------------------------------------
1 | package request
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "net/http"
8 | "social-network/pkg/model"
9 | )
10 |
11 | type Payload struct {
12 | Type string `json:"type"`
13 | Data json.RawMessage
14 | }
15 |
16 | type RequestT struct {
17 | Data any
18 | Ctx context.Context
19 | Middlewares []func(http.Handler, *RequestT) http.Handler
20 | }
21 |
22 | var requestTypes = map[string]any{
23 | model.TYPE_REGISTER: &Register{},
24 | model.TYPE_LOGIN: &Login{},
25 | model.Type_GET_POSTS: &GetPosts{},
26 | model.Type_GET_POST: &GetPost{},
27 | model.Type_ADD_POST: &AddPost{},
28 | model.Type_REACT_TO_POST: &ReactToPost{},
29 | model.Type_GET_POST_SHARES: &GetPostShares{},
30 | model.Type_ADD_POST_SHARE: &AddPostShare{},
31 | model.Type_REMOVE_POST_SHARE: &RemovePostShare{},
32 | model.Type_ADD_COMMENT: &AddComment{},
33 | model.Type_GET_COMMENTS: &GetComments{},
34 | model.Type_GET_PROFILE: &GetProfile{},
35 | model.Type_GET_PROFILES: nil,
36 | model.Type_SET_PROFILE_PRIVACY: &SetProfilePrivacy{},
37 | "create-group": &CreateGroup{},
38 | "get-groups": nil,
39 | "get-group-data": &GetGroupData{},
40 | "add-group-post": &AddGroupPost{},
41 | "add-group-event": &AddGroupEvent{},
42 | "invite-user-to-group": &InviteUserToGroup{},
43 | "respond-to-group-invitation": &RespondToGroupInvitation{},
44 | "get-group-invite-users": &GetGroupInviteUsers{},
45 | "add-event-option": &AddEventOption{},
46 | "request-join-group": &RequestJoinGroup{},
47 | "respond-to-join-request": &RespondToJoinRequest{},
48 | "get-join-request-count": nil,
49 | "get-unread-messages-count": nil,
50 | "add-group-comment": &AddGroupComment{},
51 | "get-group-comments": &GetGroupComments{},
52 | "react-to-group-post": &ReactToGroupPost{},
53 | "get-group-post": &GetGroupPost{},
54 | "request-follow": &RequestFollow{},
55 | "accept-follow": &AcceptFollow{},
56 | "delete-follow": &DeleteFollow{},
57 | "get-chat": nil,
58 | "get-messages": &GetMessages{},
59 | "add-message": &AddMessage{},
60 | "get-all-notifications": nil,
61 | "check-new-follow-notification": nil,
62 | "delete-follow-notification": &DeleteFollowNotification{},
63 | "delete-new-event-notification": &DeleteNewEventNotification{},
64 | "logout": nil,
65 | "update-seen-message": &UpdateSeenMessageWS{},
66 | }
67 |
68 | func (r Payload) Decode() (string, *RequestT, error) {
69 | instance, exist := requestTypes[r.Type]
70 | if !exist {
71 | return "", nil, fmt.Errorf("invalid request type %s", r.Type)
72 | }
73 |
74 | if instance != nil {
75 | err := json.Unmarshal(r.Data, instance)
76 | if err != nil {
77 | return "", nil, err
78 | }
79 | }
80 |
81 | request := &RequestT{
82 | Data: instance,
83 | }
84 |
85 | return r.Type, request, nil
86 | }
87 |
88 | func Unmarshal(data []byte) (string, *RequestT, error) {
89 | request := Payload{}
90 | err := json.Unmarshal(data, &request)
91 | if err != nil {
92 | return "", nil, err
93 | }
94 | return request.Decode()
95 | }
96 |
--------------------------------------------------------------------------------
/backend/pkg/controller/groupComment.go:
--------------------------------------------------------------------------------
1 | package app
2 |
3 | import (
4 | "log"
5 | "social-network/pkg/model"
6 | "social-network/pkg/model/request"
7 | "social-network/pkg/model/response"
8 | )
9 |
10 | func (app *App) AddGroupComment(payload *request.RequestT) any {
11 | data, ok := payload.Data.(*request.AddGroupComment)
12 | if !ok {
13 | return &response.Error{Code: 400, Cause: "Invalid payload type"}
14 | }
15 | userId := payload.Ctx.Value("user_id").(int)
16 |
17 | if data.Text == "" && data.Image == "" {
18 | return &response.Error{Code: 400, Cause: "Comment text cannot be empty"}
19 | }
20 | if len(data.Text) > 500 {
21 | return &response.Error{Code: 400, Cause: "Comment text exceeds maximum length of 500 characters"}
22 | }
23 |
24 | isMember, err := app.repository.Group().IsGroupPostMember(userId, data.PostId)
25 | if err != nil || !isMember {
26 | return &response.Error{Code: 403, Cause: "You are not a member of this group"}
27 | }
28 |
29 | user, err := app.repository.User().Find(userId)
30 | if err != nil {
31 | return &response.Error{Code: 500, Cause: "Error finding user"}
32 | }
33 |
34 | comment := &model.Comment{
35 | UserId: userId,
36 | PostId: data.PostId,
37 | Text: data.Text,
38 | Image: data.Image,
39 | Author: user.Username,
40 | }
41 |
42 | if len(comment.Text) > 10000 {
43 | log.Println("Error adding group comment:")
44 | return &response.Error{Code: 400, Cause: "Comment cannot be too large"}
45 | }
46 |
47 | err = app.repository.Group().AddGroupComment(comment)
48 | if err != nil {
49 | log.Println("Error adding group comment:", err)
50 | return &response.Error{Code: 500, Cause: "Error adding comment: " + err.Error()}
51 | }
52 |
53 | comments, err := app.repository.Group().GetGroupCommentsByPostId(data.PostId)
54 | if err != nil {
55 | log.Println("Error getting group comments:", err)
56 | return &response.Error{Code: 500, Cause: "Error getting comments: " + err.Error()}
57 | }
58 |
59 | post, err := app.repository.Group().GetGroupPostById(userId, data.PostId)
60 | if err != nil {
61 | log.Println("Error getting group post data:", err)
62 | return &response.Error{Code: 500, Cause: "Error getting post data: " + err.Error()}
63 | }
64 |
65 | post.Comment = comments
66 | return &response.AddGroupComment{
67 | Post: post,
68 | }
69 | }
70 |
71 | func (app *App) GetGroupComments(payload *request.RequestT) any {
72 | data, ok := payload.Data.(*request.GetGroupComments)
73 | if !ok {
74 | return &response.Error{Code: 400, Cause: "Invalid payload type"}
75 | }
76 | userId := payload.Ctx.Value("user_id").(int)
77 |
78 | isMember, err := app.repository.Group().IsGroupPostMember(userId, data.PostId)
79 | if err != nil || !isMember {
80 | return &response.Error{Code: 403, Cause: "You are not a member of this group"}
81 | }
82 |
83 | comments, err := app.repository.Group().GetGroupCommentsByPostId(data.PostId)
84 | if err != nil {
85 | log.Println("Error getting group comments:", err)
86 | return &response.Error{Code: 500, Cause: "Error getting comments: " + err.Error()}
87 | }
88 |
89 | post, err := app.repository.Group().GetGroupPostById(userId, data.PostId)
90 | if err != nil {
91 | log.Println("Error getting group post data:", err)
92 | return &response.Error{Code: 500, Cause: "Error getting post data: " + err.Error()}
93 | }
94 |
95 | post.Comment = comments
96 | return &response.GetGroupComments{
97 | Post: post,
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/backend/pkg/repository/groupCommentRepo.go:
--------------------------------------------------------------------------------
1 | package repository
2 |
3 | import (
4 | "social-network/pkg/model"
5 | "time"
6 | )
7 |
8 | func (r *GroupRepository) AddGroupComment(c *model.Comment) error {
9 | return r.Repository.db.QueryRow(
10 | "INSERT INTO group_comments (user_id, post_id, text, image) VALUES ($1, $2, $3, $4) RETURNING id",
11 | c.UserId, c.PostId, c.Text, c.Image,
12 | ).Scan(&c.ID)
13 | }
14 |
15 | func (r *GroupRepository) GetGroupCommentsByPostId(postId int) ([]*model.Comment, error) {
16 | var comments []*model.Comment
17 |
18 | query := `
19 | SELECT
20 | gc.id,
21 | gc.user_id,
22 | users.avatar,
23 | COALESCE(users.firstname || ' ' || users.lastname, '') AS author,
24 | gc.post_id,
25 | gc.text,
26 | gc.image,
27 | gc.creation_date
28 | FROM group_comments gc
29 | JOIN users ON users.id = gc.user_id
30 | WHERE gc.post_id = $1
31 | ORDER BY gc.creation_date DESC;
32 | `
33 |
34 | rows, err := r.Repository.db.Query(query, postId)
35 | if err != nil {
36 | return nil, err
37 | }
38 | defer rows.Close()
39 |
40 | for rows.Next() {
41 | comment := &model.Comment{}
42 | if err := rows.Scan(&comment.ID, &comment.UserId, &comment.UserAvatar, &comment.Author, &comment.PostId, &comment.Text, &comment.Image, &comment.CreationDate); err != nil {
43 | return nil, err
44 | }
45 | newTime, _ := time.Parse("2006-01-02T15:04:05Z", comment.CreationDate)
46 | comment.CreationDate = newTime.Format("2006-01-02 15:04:05")
47 | comment.PostId = postId
48 | comments = append(comments, comment)
49 | }
50 |
51 | if len(comments) == 0 {
52 | return []*model.Comment{}, nil
53 | }
54 | return comments, nil
55 | }
56 |
57 | func (r *GroupRepository) IsGroupPostMember(userId, postId int) (bool, error) {
58 | var isMember bool
59 | query := `
60 | SELECT EXISTS(
61 | SELECT 1
62 | FROM group_posts gp
63 | JOIN group_members gm ON gm.group_id = gp.group_id
64 | WHERE gp.id = $1 AND gm.user_id = $2 AND gm.is_accepted = TRUE
65 | )`
66 | err := r.Repository.db.QueryRow(query, postId, userId).Scan(&isMember)
67 | return isMember, err
68 | }
69 |
70 | func (r *GroupRepository) GetGroupPostById(userId, postId int) (*model.Post, error) {
71 | post := &model.Post{}
72 | user := &model.User{}
73 |
74 | query := `
75 | SELECT
76 | gp.id, gp.user_id, gp.caption, gp.image, gp.creation_date,
77 | u.firstname, u.lastname, u.avatar
78 | FROM group_posts gp
79 | JOIN users u ON u.id = gp.user_id
80 | WHERE gp.id = $1
81 | `
82 |
83 | err := r.Repository.db.QueryRow(query, postId).Scan(
84 | &post.ID, &post.UserId, &post.Caption, &post.Image, &post.CreationDate,
85 | &user.Firstname, &user.Lastname, &user.Avatar,
86 | )
87 | if err != nil {
88 | return nil, err
89 | }
90 |
91 | post.User = user
92 |
93 | // Get reaction counts
94 | reactions, err := r.GetGroupReactionCounts(postId)
95 | if err != nil {
96 | return nil, err
97 | }
98 |
99 | // Get user's reaction
100 | userReaction, err := r.GetGroupUserReaction(userId, postId)
101 | if err != nil {
102 | return nil, err
103 | }
104 |
105 | reactions.UserReaction = userReaction
106 | post.Reactions = reactions
107 |
108 | return post, nil
109 | }
110 |
111 | func (r *GroupRepository) GetGroupCommentCountByPostId(postId int) (int, error) {
112 | var count int
113 | err := r.Repository.db.QueryRow(`
114 | SELECT COUNT(*)
115 | FROM group_comments
116 | WHERE post_id = $1;
117 | `, postId).Scan(&count)
118 |
119 | if err != nil {
120 | return 0, err
121 | }
122 | return count, nil
123 | }
124 |
--------------------------------------------------------------------------------
/backend/pkg/repository/postShareRepo.go:
--------------------------------------------------------------------------------
1 | package repository
2 |
3 | import (
4 | "database/sql"
5 | "social-network/pkg/model"
6 | )
7 |
8 | type PostShareRepository struct {
9 | Repository *Repository
10 | }
11 |
12 | // GetPostShares returns all users who have access to a specific private post
13 | func (r *PostShareRepository) GetPostShares(postId int) ([]*model.User, error) {
14 | var users []*model.User
15 | query := `SELECT u.id, u.firstname, u.lastname, u.nickname, u.avatar
16 | FROM post_shares ps
17 | JOIN users u ON ps.shared_with_user_id = u.id
18 | WHERE ps.post_id = $1
19 | ORDER BY u.firstname, u.lastname`
20 |
21 | rows, err := r.Repository.db.Query(query, postId)
22 | if err != nil {
23 | return nil, err
24 | }
25 | defer rows.Close()
26 |
27 | for rows.Next() {
28 | user := &model.User{}
29 | if err := rows.Scan(&user.ID, &user.Firstname, &user.Lastname, &user.Nickname, &user.Avatar); err != nil {
30 | return nil, err
31 | }
32 | users = append(users, user)
33 | }
34 |
35 | if err := rows.Err(); err != nil {
36 | return nil, err
37 | }
38 |
39 | return users, nil
40 | }
41 |
42 | // GetAvailableUsersToShare returns followers who don't already have access to the private post
43 | func (r *PostShareRepository) GetAvailableUsersToShare(postId, postOwnerId int) ([]*model.User, error) {
44 | var users []*model.User
45 | query := `SELECT u.id, u.firstname, u.lastname, u.nickname, u.avatar
46 | FROM users u
47 | JOIN followers f ON u.id = f.follower_id
48 | WHERE f.following_id = $1
49 | AND f.is_accepted = TRUE
50 | AND u.id NOT IN (
51 | SELECT ps.shared_with_user_id
52 | FROM post_shares ps
53 | WHERE ps.post_id = $2
54 | )
55 | ORDER BY u.firstname, u.lastname`
56 |
57 | rows, err := r.Repository.db.Query(query, postOwnerId, postId)
58 | if err != nil {
59 | return nil, err
60 | }
61 | defer rows.Close()
62 |
63 | for rows.Next() {
64 | user := &model.User{}
65 | if err := rows.Scan(&user.ID, &user.Firstname, &user.Lastname, &user.Nickname, &user.Avatar); err != nil {
66 | return nil, err
67 | }
68 | users = append(users, user)
69 | }
70 |
71 | if err := rows.Err(); err != nil {
72 | return nil, err
73 | }
74 |
75 | return users, nil
76 | }
77 |
78 | // AddPostShare adds a user to the post shares
79 | func (r *PostShareRepository) AddPostShare(postId, userId int) error {
80 | // Check if share already exists
81 | var existingId int
82 | checkQuery := `SELECT id FROM post_shares WHERE post_id = $1 AND shared_with_user_id = $2`
83 | err := r.Repository.db.QueryRow(checkQuery, postId, userId).Scan(&existingId)
84 |
85 | if err == nil {
86 | return nil // Share already exists
87 | }
88 |
89 | if err != sql.ErrNoRows {
90 | return err
91 | }
92 |
93 | // Add new share
94 | query := `INSERT INTO post_shares (post_id, shared_with_user_id) VALUES ($1, $2)`
95 | _, err = r.Repository.db.Exec(query, postId, userId)
96 | return err
97 | }
98 |
99 | // RemovePostShare removes a user from the post shares
100 | func (r *PostShareRepository) RemovePostShare(postId, userId int) error {
101 | query := `DELETE FROM post_shares WHERE post_id = $1 AND shared_with_user_id = $2`
102 | _, err := r.Repository.db.Exec(query, postId, userId)
103 | return err
104 | }
105 |
106 | // VerifyPostOwnership checks if the user owns the post
107 | func (r *PostShareRepository) VerifyPostOwnership(postId, userId int) (bool, error) {
108 | var ownerId int
109 | query := `SELECT user_id FROM posts WHERE id = $1`
110 | err := r.Repository.db.QueryRow(query, postId).Scan(&ownerId)
111 |
112 | if err != nil {
113 | return false, err
114 | }
115 |
116 | return ownerId == userId, nil
117 | }
118 |
--------------------------------------------------------------------------------
/backend/pkg/controller/postShare.go:
--------------------------------------------------------------------------------
1 | package app
2 |
3 | import (
4 | "log"
5 | "social-network/pkg/model/request"
6 | "social-network/pkg/model/response"
7 | )
8 |
9 | func (app *App) GetPostShares(payload *request.RequestT) any {
10 | data, ok := payload.Data.(*request.GetPostShares)
11 | if !ok {
12 | return &response.Error{Code: 400, Cause: "Invalid payload type"}
13 | }
14 |
15 | userId := payload.Ctx.Value("user_id").(int)
16 |
17 | isOwner, err := app.repository.PostShare().VerifyPostOwnership(data.PostId, userId)
18 | if err != nil {
19 | log.Println("Error verifying post ownership:", err)
20 | return &response.Error{Code: 500, Cause: "Error verifying post ownership"}
21 | }
22 | if !isOwner {
23 | return &response.Error{Code: 403, Cause: "You don't have permission to manage this post"}
24 | }
25 |
26 | currentShares, err := app.repository.PostShare().GetPostShares(data.PostId)
27 | if err != nil {
28 | log.Println("Error getting post shares:", err)
29 | return &response.Error{Code: 500, Cause: "Error retrieving post shares"}
30 | }
31 |
32 | availableUsers, err := app.repository.PostShare().GetAvailableUsersToShare(data.PostId, userId)
33 | if err != nil {
34 | log.Println("Error getting available users:", err)
35 | return &response.Error{Code: 500, Cause: "Error retrieving available users"}
36 | }
37 |
38 | for _, user := range currentShares {
39 | user.IsAccepted = true
40 | }
41 | for _, user := range availableUsers {
42 | user.IsAccepted = false
43 | }
44 |
45 | allUsers := append(currentShares, availableUsers...)
46 |
47 | return &response.GetPostShares{
48 | AllUsers: allUsers,
49 | Success: true,
50 | }
51 | }
52 |
53 | func (app *App) AddPostShare(payload *request.RequestT) any {
54 | data, ok := payload.Data.(*request.AddPostShare)
55 | if !ok {
56 | return &response.Error{Code: 400, Cause: "Invalid payload type"}
57 | }
58 |
59 | userId := payload.Ctx.Value("user_id").(int)
60 |
61 | isOwner, err := app.repository.PostShare().VerifyPostOwnership(data.PostId, userId)
62 | if err != nil {
63 | log.Println("Error verifying post ownership:", err)
64 | return &response.Error{Code: 500, Cause: "Error verifying post ownership"}
65 | }
66 | if !isOwner {
67 | return &response.Error{Code: 403, Cause: "You don't have permission to manage this post"}
68 | }
69 |
70 | err = app.repository.PostShare().AddPostShare(data.PostId, data.UserId)
71 | if err != nil {
72 | log.Println("Error adding post share:", err)
73 | return &response.Error{Code: 500, Cause: "Error adding post share"}
74 | }
75 |
76 | return &response.AddPostShare{
77 | Message: "User added successfully",
78 | Success: true,
79 | }
80 | }
81 |
82 | func (app *App) RemovePostShare(payload *request.RequestT) any {
83 | data, ok := payload.Data.(*request.RemovePostShare)
84 | if !ok {
85 | return &response.Error{Code: 400, Cause: "Invalid payload type"}
86 | }
87 |
88 | userId := payload.Ctx.Value("user_id").(int)
89 |
90 | isOwner, err := app.repository.PostShare().VerifyPostOwnership(data.PostId, userId)
91 | if err != nil {
92 | log.Println("Error verifying post ownership:", err)
93 | return &response.Error{Code: 500, Cause: "Error verifying post ownership"}
94 | }
95 | if !isOwner {
96 | return &response.Error{Code: 403, Cause: "You don't have permission to manage this post"}
97 | }
98 |
99 | err = app.repository.PostShare().RemovePostShare(data.PostId, data.UserId)
100 | if err != nil {
101 | log.Println("Error removing post share:", err)
102 | return &response.Error{Code: 500, Cause: "Error removing post share"}
103 | }
104 |
105 | return &response.RemovePostShare{
106 | Message: "User removed successfully",
107 | Success: true,
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/backend/pkg/model/request/register.go:
--------------------------------------------------------------------------------
1 | package request
2 |
3 | import (
4 | "regexp"
5 | "social-network/pkg/model/response"
6 | "strings"
7 | "time"
8 |
9 | "golang.org/x/crypto/bcrypt"
10 | )
11 |
12 | type Register struct {
13 | Email string `json:"email"`
14 | Password string `json:"password"`
15 | Firstname string `json:"firstname"`
16 | Lastname string `json:"lastname"`
17 | Birth string `json:"birth"`
18 | Nickname string `json:"nickname"`
19 | About string `json:"about"`
20 | Avatar string `json:"avatar"`
21 | }
22 |
23 | var (
24 | emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
25 | nameRegex = regexp.MustCompile(`^[a-zA-Z]{1,50}$`)
26 | nicknameRegex = regexp.MustCompile(`^[a-zA-Z0-9.]{3,25}$`)
27 | )
28 |
29 | func (r *Register) Validate() (err *response.RegisterError) {
30 | err = &response.RegisterError{}
31 | err.Code = 400
32 | r.Email = strings.ToLower(strings.TrimSpace(r.Email))
33 | r.Firstname = strings.TrimSpace(r.Firstname)
34 | r.Lastname = strings.TrimSpace(r.Lastname)
35 | r.Nickname = strings.ToLower(strings.TrimSpace(r.Nickname))
36 | r.About = strings.TrimSpace(r.About)
37 |
38 | if !emailRegex.MatchString(r.Email) {
39 | err.Field = "email"
40 | err.Cause = "invalid email format"
41 | return
42 | }
43 |
44 | if !nameRegex.MatchString(r.Firstname) {
45 | err.Field = "firstName"
46 | err.Cause = "only letters allowed, 1-50 chars"
47 | return
48 | }
49 |
50 | if !nameRegex.MatchString(r.Lastname) {
51 | err.Field = "lastName"
52 | err.Cause = "only letters allowed, 1-50 chars"
53 | return
54 | }
55 |
56 | if len(r.Nickname) > 0 && !nicknameRegex.MatchString(r.Nickname) {
57 | err.Field = "nickname"
58 | err.Cause = "3-25 chars, letters/numbers/dots only"
59 | return
60 | }
61 |
62 | if len(r.About) > 1000 {
63 | err.Field = "about"
64 | err.Cause = "about section too long (max 1000 characters)"
65 | return
66 | }
67 |
68 | if passErr := r.ValidatePassword(); passErr != "" {
69 | err.Field = "password"
70 | err.Cause = passErr
71 | return
72 | }
73 |
74 | if birthErr := r.ValidateBirth(); birthErr != "" {
75 | err.Field = "birth"
76 | err.Cause = birthErr
77 | return
78 | }
79 | if hashErr := r.HashPassword(); hashErr != "" {
80 | err.Code = 500
81 | err.Field = "password"
82 | err.Cause = hashErr
83 | }
84 | return nil
85 | }
86 |
87 | func (r *Register) ValidateBirth() string {
88 | now := time.Now()
89 | newTime, err := time.Parse(time.DateOnly, r.Birth)
90 | if err != nil {
91 | return "invalid birth date format, use mm-DD-YYYY"
92 | }
93 | if newTime.After(now) {
94 | return "birth date must be in the past"
95 | }
96 | if !(newTime.After(now.Add(-time.Hour*24*365*100)) && newTime.Before(now.Add(-time.Hour*24*365*18))) {
97 | return "birth date must be Between 18 and 100 years"
98 | }
99 |
100 | return ""
101 | }
102 |
103 | func (r *Register) HashPassword() string {
104 | passwd, err := bcrypt.GenerateFromPassword([]byte(r.Password), bcrypt.DefaultCost)
105 | if err != nil {
106 | //log in error file
107 | return "Can't hash password"
108 | }
109 | r.Password = string(passwd)
110 | return ""
111 | }
112 |
113 | func (r *Register) ValidatePassword() (err string) {
114 | if len(r.Password) < 6 || len(r.Password) > 100 {
115 | return "password must be 6-100 characters"
116 | }
117 | hasLetter := false
118 | hasDigit := false
119 | for _, c := range r.Password {
120 | switch {
121 | case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z':
122 | hasLetter = true
123 | case c >= '0' && c <= '9':
124 | hasDigit = true
125 | }
126 | }
127 |
128 | if !hasLetter || !hasDigit {
129 | return "must contain both letters and digits"
130 | }
131 |
132 | return ""
133 | }
134 |
--------------------------------------------------------------------------------
/backend/pkg/model/request/types.go:
--------------------------------------------------------------------------------
1 | package request
2 |
3 | type Login struct {
4 | Email string `json:"email"`
5 | Password string `json:"password"`
6 | }
7 |
8 | type GetPosts struct {
9 | StartId int `json:"startId"`
10 | }
11 |
12 | type GetPost struct {
13 | PostId int `json:"postId"`
14 | }
15 |
16 | type AddPost struct {
17 | Caption string `json:"caption"`
18 | Privacy string `json:"privacy"`
19 | Image string `json:"image"`
20 | }
21 |
22 | type ReactToPost struct {
23 | PostId int `json:"postId"`
24 | Reaction *bool `json:"reaction"`
25 | }
26 |
27 | type GetPostShares struct {
28 | PostId int `json:"postId"`
29 | }
30 |
31 | type AddPostShare struct {
32 | PostId int `json:"postId"`
33 | UserId int `json:"userId"`
34 | }
35 |
36 | type RemovePostShare struct {
37 | PostId int `json:"postId"`
38 | UserId int `json:"userId"`
39 | }
40 |
41 | type AddComment struct {
42 | PostId int `json:"postId"`
43 | Text string `json:"text"`
44 | Image string `json:"image"`
45 | }
46 |
47 | type GetComments struct {
48 | PostId int `json:"postId"`
49 | }
50 |
51 | type GetProfile struct {
52 | ProfileId int `json:"profileId"`
53 | }
54 |
55 | type SetProfilePrivacy struct {
56 | State bool `json:"state"`
57 | }
58 |
59 | type CreateGroup struct {
60 | Title string `json:"title"`
61 | Description string `json:"description"`
62 | Image string `json:"image"`
63 | }
64 |
65 | type GetGroupData struct {
66 | GroupId int `json:"groupId"`
67 | }
68 |
69 | type AddGroupPost struct {
70 | GroupId int `json:"groupId"`
71 | Caption string `json:"caption"`
72 | Image string `json:"image"`
73 | }
74 |
75 | type AddGroupEvent struct {
76 | GroupId int `json:"groupId"`
77 | Title string `json:"title"`
78 | Description string `json:"description"`
79 | Option1 string `json:"option1"`
80 | Option2 string `json:"option2"`
81 | Date string `json:"date"`
82 | Place string `json:"place"`
83 | }
84 |
85 | type GetGroupInviteUsers struct {
86 | GroupId int `json:"groupId"`
87 | }
88 |
89 | type InviteUserToGroup struct {
90 | GroupId int `json:"groupId"`
91 | UserId int `json:"userId"`
92 | }
93 |
94 | type RespondToGroupInvitation struct {
95 | GroupId int `json:"groupId"`
96 | Accept bool `json:"accept"`
97 | }
98 |
99 | type AddEventOption struct {
100 | GroupId int `json:"groupId"`
101 | EventId int `json:"eventId"`
102 | Option bool `json:"option"`
103 | }
104 |
105 | type RequestJoinGroup struct {
106 | GroupId int `json:"groupId"`
107 | }
108 |
109 | type RespondToJoinRequest struct {
110 | GroupId int `json:"groupId"`
111 | UserId int `json:"userId"`
112 | IsAccepted bool `json:"isAccepted"`
113 | }
114 |
115 | type AddGroupComment struct {
116 | PostId int `json:"postId"`
117 | Text string `json:"text"`
118 | Image string `json:"image"`
119 | GroupId int `json:"groupId"`
120 | }
121 |
122 | type GetGroupComments struct {
123 | PostId int `json:"postId"`
124 | GroupId int `json:"groupId"`
125 | }
126 |
127 | type ReactToGroupPost struct {
128 | PostId int `json:"postId"`
129 | GroupId int `json:"groupId"`
130 | Reaction *bool `json:"reaction"`
131 | }
132 |
133 | type GetGroupPost struct {
134 | PostId int `json:"postId"`
135 | }
136 |
137 | type RequestFollow struct {
138 | ProfileId int `json:"profileId"`
139 | }
140 |
141 | type AcceptFollow struct {
142 | ProfileId int `json:"profileId"`
143 | }
144 |
145 | type DeleteFollow struct {
146 | ProfileId int `json:"profileId"`
147 | IsFollower bool `json:"isFollower"`
148 | }
149 |
150 | type GetMessages struct {
151 | Id int `json:"id"`
152 | IsGroup bool `json:"isGroup"`
153 | }
154 |
155 | type AddMessage struct {
156 | Id int `json:"id"`
157 | IsGroup bool `json:"isGroup"`
158 | Message string `json:"message"`
159 | Session string `json:"session"`
160 | }
161 |
162 | type DeleteFollowNotification struct {
163 | ProfileId int `json:"profileId"`
164 | }
165 |
166 | type DeleteNewEventNotification struct {
167 | GroupId int `json:"groupId"`
168 | }
169 |
170 | type UpdateSeenMessageWS struct {
171 | Id int `json:"id"`
172 | IsGroup bool `json:"isGroup"`
173 | Session string `json:"session"`
174 | }
175 |
--------------------------------------------------------------------------------
/backend/pkg/controller/notification.go:
--------------------------------------------------------------------------------
1 | package app
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "social-network/pkg/model/request"
7 | "social-network/pkg/model/response"
8 | "time"
9 | )
10 |
11 | func (app *App) GetAllNotifications(payload *request.RequestT) any {
12 | userId := payload.Ctx.Value("user_id").(int)
13 |
14 | pmCount, err := app.repository.Message().CountUnreadPM(userId)
15 | if err != nil {
16 | log.Println("Error getting unread PM count:", err)
17 | pmCount = 0
18 | }
19 |
20 | groupMessageCount, err := app.repository.Message().CountUnreadGroup(userId)
21 | if err != nil {
22 | log.Println("Error getting unread group messages:", err)
23 | groupMessageCount = 0
24 | }
25 |
26 | messageUnread := pmCount + groupMessageCount
27 |
28 | groupRequests, err := app.repository.Group().CountPendingJoinRequests(userId)
29 | if err != nil {
30 | log.Println("Error getting group join requests:", err)
31 | groupRequests = 0
32 | }
33 | fmt.Println("Group Requests Count:", groupRequests)
34 |
35 | followRequests, err := app.repository.Follow().GetFollowRequestCount(userId)
36 | if err != nil {
37 | log.Println("Error getting follow requests:", err)
38 | followRequests = 0
39 | }
40 | publicFollowRequests, err := app.repository.Follow().CountPublicFollowRequests(userId)
41 | if err != nil {
42 | log.Println("Error getting public follow requests:", err)
43 | publicFollowRequests = 0
44 | }
45 | followRequests += publicFollowRequests
46 |
47 | eventCreatedCount, err := app.repository.Group().CountNewEvents(userId)
48 | if err != nil {
49 | log.Println("Error getting event created count:", err)
50 | eventCreatedCount = 0
51 | }
52 | groupRequests += eventCreatedCount
53 |
54 | invitations, err := app.repository.Group().GetGroupJoinInvitations(userId)
55 | if err != nil {
56 | log.Println("Error getting event created count:", err)
57 | invitations = 0
58 | }
59 | fmt.Println("Invitations Count:", invitations)
60 | groupRequests += invitations
61 |
62 | fmt.Println(userId, groupRequests, invitations)
63 | notifications := map[string]int{
64 | "messageUnread": messageUnread,
65 | "groupRequests": groupRequests,
66 | "followRequests": followRequests,
67 | }
68 | totalCount := messageUnread + groupRequests + followRequests
69 |
70 | return &response.GetAllNotifications{
71 | Notifications: notifications,
72 | TotalCount: totalCount,
73 | }
74 | }
75 |
76 | func (app *App) CheckNewFollowNotification(payload *request.RequestT) any {
77 | userId := payload.Ctx.Value("user_id").(int)
78 |
79 | users, err := app.repository.Follow().GetNewFollowers(userId)
80 | if err != nil {
81 | return &response.Error{Code: 500, Cause: "Database error"}
82 | }
83 |
84 | return &response.CheckNewFollowNotification{
85 | HasNewFollow: len(users) > 0,
86 | NewFollowers: users,
87 | }
88 | }
89 |
90 | func (app *App) DeleteFollowNotification(payload *request.RequestT) any {
91 | data, ok := payload.Data.(*request.DeleteFollowNotification)
92 | if !ok {
93 | return &response.Error{Code: 400, Cause: "Invalid payload type"}
94 | }
95 | userId := payload.Ctx.Value("user_id").(int)
96 |
97 | err := app.repository.Follow().DeleteNotif(data.ProfileId, userId)
98 | if err != nil {
99 | log.Println("Error deleting follow notification:", err)
100 | return &response.Error{Code: 500, Cause: "Error deleting follow notification"}
101 | }
102 | notification := map[string]any{
103 | "type": "notifications",
104 | "followerId": data.ProfileId,
105 | "message": "unfollow ",
106 | "timestamp": time.Now().Unix(),
107 | }
108 | app.sendNotificationToUser(userId, notification)
109 | return &response.DeleteFollowNotification{
110 | Message: "Notification deleted",
111 | }
112 | }
113 |
114 | func (app *App) DeleteNewEventNotification(payload *request.RequestT) any {
115 | data, ok := payload.Data.(*request.DeleteNewEventNotification)
116 | if !ok {
117 | return &response.Error{Code: 400, Cause: "Invalid payload type"}
118 | }
119 | userId := payload.Ctx.Value("user_id").(int)
120 |
121 | err := app.repository.Follow().DeleteEventNotif(data.GroupId, userId)
122 | if err != nil {
123 | log.Println("Error deleting follow notification:", err)
124 | return &response.Error{Code: 500, Cause: "Error deleting follow notification"}
125 | }
126 | notification := map[string]any{
127 | "type": "notifications",
128 | }
129 | app.sendNotificationToUser(userId, notification)
130 | return &response.DeleteNewEventNotification{
131 | Message: "Notification deleted",
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/backend/pkg/repository/groupReactionRepo.go:
--------------------------------------------------------------------------------
1 | package repository
2 |
3 | import (
4 | "database/sql"
5 | "errors"
6 | "social-network/pkg/model"
7 | )
8 |
9 | func (r *GroupRepository) UpsertGroupReaction(userId, postId int, isLike *bool) error {
10 | var exists bool
11 | err := r.Repository.db.QueryRow("SELECT EXISTS(SELECT 1 FROM group_posts WHERE id = $1)", postId).Scan(&exists)
12 | if err != nil {
13 | return err
14 | }
15 | if !exists {
16 | return errors.New("group post does not exist")
17 | }
18 |
19 | if isLike == nil {
20 | _, err := r.Repository.db.Exec("DELETE FROM group_post_reactions WHERE user_id = $1 AND post_id = $2", userId, postId)
21 | return err
22 | }
23 |
24 | _, err = r.Repository.db.Exec(`
25 | INSERT INTO group_post_reactions (user_id, post_id, is_like)
26 | VALUES ($1, $2, $3)
27 | ON CONFLICT(user_id, post_id)
28 | DO UPDATE SET is_like = $3`,
29 | userId, postId, isLike)
30 |
31 | return err
32 | }
33 |
34 | func (r *GroupRepository) GetGroupUserReaction(userId, postId int) (*bool, error) {
35 | var isLike sql.NullBool
36 | err := r.Repository.db.QueryRow(
37 | "SELECT is_like FROM group_post_reactions WHERE user_id = $1 AND post_id = $2",
38 | userId, postId,
39 | ).Scan(&isLike)
40 |
41 | if err == sql.ErrNoRows {
42 | return nil, nil // No reaction
43 | }
44 |
45 | if err != nil {
46 | return nil, err
47 | }
48 |
49 | if !isLike.Valid {
50 | return nil, nil
51 | }
52 |
53 | value := isLike.Bool
54 | return &value, nil
55 | }
56 |
57 | func (r *GroupRepository) GetGroupReactionCounts(postId int) (model.ReactionCounts, error) {
58 | counts := model.ReactionCounts{}
59 |
60 | err := r.Repository.db.QueryRow(
61 | "SELECT COUNT(*) FROM group_post_reactions WHERE post_id = $1 AND is_like = TRUE",
62 | postId,
63 | ).Scan(&counts.Likes)
64 |
65 | if err != nil {
66 | return counts, err
67 | }
68 |
69 | err = r.Repository.db.QueryRow(
70 | "SELECT COUNT(*) FROM group_post_reactions WHERE post_id = $1 AND is_like = FALSE",
71 | postId,
72 | ).Scan(&counts.Dislikes)
73 |
74 | return counts, err
75 | }
76 |
77 | func (r *GroupRepository) GetGroupReactionsForPosts(userId int, postIds []int) (map[int]model.ReactionCounts, error) {
78 | if len(postIds) == 0 {
79 | return make(map[int]model.ReactionCounts), nil
80 | }
81 |
82 | // Initialize result map
83 | result := make(map[int]model.ReactionCounts)
84 |
85 | // Prepare placeholders for query
86 | placeholders := ""
87 | args := make([]interface{}, len(postIds))
88 |
89 | for i, id := range postIds {
90 | if i > 0 {
91 | placeholders += ", "
92 | }
93 | placeholders += "$" + string(rune('2'+i))
94 | args[i] = id
95 | }
96 |
97 | // Get all likes and dislikes counts
98 | query := `
99 | SELECT post_id,
100 | SUM(CASE WHEN is_like = TRUE THEN 1 ELSE 0 END) as likes,
101 | SUM(CASE WHEN is_like = FALSE THEN 1 ELSE 0 END) as dislikes
102 | FROM group_post_reactions
103 | WHERE post_id IN (` + placeholders + `)
104 | GROUP BY post_id
105 | `
106 |
107 | rows, err := r.Repository.db.Query(query, args...)
108 | if err != nil {
109 | return nil, err
110 | }
111 | defer rows.Close()
112 |
113 | for rows.Next() {
114 | var postId, likes, dislikes int
115 | if err := rows.Scan(&postId, &likes, &dislikes); err != nil {
116 | return nil, err
117 | }
118 |
119 | result[postId] = model.ReactionCounts{
120 | Likes: likes,
121 | Dislikes: dislikes,
122 | }
123 | }
124 |
125 | // Get user's own reactions
126 | if userId > 0 {
127 | query = `
128 | SELECT post_id, is_like
129 | FROM group_post_reactions
130 | WHERE user_id = $1 AND post_id IN (` + placeholders + `)
131 | `
132 |
133 | args = append([]interface{}{userId}, args...)
134 |
135 | rows, err := r.Repository.db.Query(query, args...)
136 | if err != nil {
137 | return nil, err
138 | }
139 | defer rows.Close()
140 |
141 | for rows.Next() {
142 | var postId int
143 | var isLike sql.NullBool
144 |
145 | if err := rows.Scan(&postId, &isLike); err != nil {
146 | return nil, err
147 | }
148 |
149 | counts, exists := result[postId]
150 | if !exists {
151 | counts = model.ReactionCounts{}
152 | }
153 |
154 | if isLike.Valid {
155 | value := isLike.Bool
156 | counts.UserReaction = &value
157 | }
158 |
159 | result[postId] = counts
160 | }
161 | }
162 |
163 | // Initialize counts for posts without reactions
164 | for _, postId := range postIds {
165 | if _, exists := result[postId]; !exists {
166 | result[postId] = model.ReactionCounts{
167 | Likes: 0,
168 | Dislikes: 0,
169 | }
170 | }
171 | }
172 |
173 | return result, nil
174 | }
175 |
--------------------------------------------------------------------------------
/frontend/app/home/(posts)/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useCallback, useEffect, useState } from "react";
4 | import CreatePostModal from "@/components/create-post-modal";
5 | import Post from "@/components/post";
6 | import { Post as PostType, Reaction } from "@/types/post";
7 | import { useGlobalAPIHelper } from "@/helpers/GlobalAPIHelper";
8 |
9 | export default function PostsPage() {
10 | const { apiCall } = useGlobalAPIHelper();
11 | const [posts, setPosts] = useState([]);
12 | const [currentUserId, setCurrentUserId] = useState(null);
13 | const [isModalOpen, setIsModalOpen] = useState(false);
14 | const [isLoading, setIsLoading] = useState(true);
15 | const [hasMore, setHasMore] = useState(true);
16 | const fetchPosts = useCallback(async () => {
17 | setIsLoading(true);
18 | const data = await apiCall(
19 | {
20 | type: "get-posts",
21 | data: { startId: 0 },
22 | },
23 | "POST",
24 | "getPosts"
25 | );
26 |
27 | if (data?.posts) {
28 | const { posts, userid } = data;
29 | setPosts(posts);
30 | setCurrentUserId(userid);
31 | setHasMore(posts.length === 10);
32 | }
33 |
34 | setIsLoading(false);
35 | }, [apiCall]);
36 |
37 | const fetchMorePosts = async () => {
38 | if (posts.length === 0) return;
39 |
40 | const lastId = posts[posts.length - 1].id;
41 | setIsLoading(true);
42 |
43 | const data = await apiCall(
44 | {
45 | type: "get-posts",
46 | data: { startId: lastId },
47 | },
48 | "POST",
49 | "getPosts"
50 | );
51 |
52 | if (data?.posts?.length) {
53 | setPosts((prev) => [...prev, ...data.posts]);
54 | setHasMore(data.posts.length === 10);
55 | }
56 |
57 | setIsLoading(false);
58 | };
59 |
60 | useEffect(() => {
61 | fetchPosts();
62 | }, [fetchPosts]);
63 |
64 | const handleCreatePost = async (newPost: {
65 | image: string;
66 | caption: string;
67 | privacy?: string;
68 | groupId?: number;
69 | }) => {
70 | try {
71 | const data = await apiCall(
72 | {
73 | type: "add-post",
74 | data: newPost,
75 | },
76 | "POST",
77 | "addPost"
78 | );
79 |
80 | if (data?.post) {
81 | setPosts([data.post, ...posts]);
82 | } else {
83 | return;
84 | }
85 | } catch (err) {
86 | console.log(err);
87 | }
88 |
89 | setIsModalOpen(false);
90 | };
91 |
92 | // Function to update post reaction in the posts list
93 | const handleReactionUpdate = (postId: number, reactionData: Reaction) => {
94 | setPosts((currentPosts) =>
95 | currentPosts.map((post) =>
96 | post.id === postId
97 | ? {
98 | ...post,
99 | reactions: {
100 | likes: reactionData.likes,
101 | dislikes: reactionData.dislikes,
102 | userReaction: reactionData.userReaction,
103 | },
104 | }
105 | : post
106 | )
107 | );
108 | };
109 |
110 | return (
111 |
112 |
113 | Posts
114 |
120 |
121 |
122 | {isLoading ? (
123 | Loading posts...
124 | ) : posts.length === 0 ? (
125 | No posts yet. Create your first post!
126 | ) : (
127 | posts.map((post) => {
128 | console.log(
129 | `Post ${post.id} - Owner: ${post.user_id}, Current User: ${currentUserId}, Privacy: ${post.privacy}`
130 | );
131 | return (
132 |
138 | );
139 | })
140 | )}
141 | {!isLoading && posts.length > 0 && hasMore && (
142 |
145 | )}
146 |
147 |
148 | {isModalOpen && (
149 |
setIsModalOpen(false)}
151 | onSubmit={handleCreatePost}
152 | />
153 | )}
154 |
155 | );
156 | }
157 |
--------------------------------------------------------------------------------
/backend/pkg/repository/reactionRepo.go:
--------------------------------------------------------------------------------
1 | package repository
2 |
3 | import (
4 | "database/sql"
5 | "errors"
6 | "social-network/pkg/model"
7 | "strings"
8 | )
9 |
10 | type ReactionRepository struct {
11 | Repository *Repository
12 | }
13 |
14 | func (r *ReactionRepository) UpsertReaction(userId, postId int, isLike *bool) error {
15 | var exists bool
16 | err := r.Repository.db.QueryRow("SELECT EXISTS(SELECT 1 FROM posts WHERE id = $1)", postId).Scan(&exists)
17 | if err != nil {
18 | return err
19 | }
20 | if !exists {
21 | return errors.New("post does not exist")
22 | }
23 |
24 | if isLike == nil {
25 | _, err := r.Repository.db.Exec("DELETE FROM post_reactions WHERE user_id = $1 AND post_id = $2", userId, postId)
26 | return err
27 | }
28 |
29 | _, err = r.Repository.db.Exec(`
30 | INSERT INTO post_reactions (user_id, post_id, is_like)
31 | VALUES ($1, $2, $3)
32 | ON CONFLICT(user_id, post_id)
33 | DO UPDATE SET is_like = $3`,
34 | userId, postId, isLike)
35 |
36 | return err
37 | }
38 |
39 | func (r *ReactionRepository) GetUserReaction(userId, postId int) (*bool, error) {
40 | var isLike sql.NullBool
41 | err := r.Repository.db.QueryRow(
42 | "SELECT is_like FROM post_reactions WHERE user_id = $1 AND post_id = $2",
43 | userId, postId,
44 | ).Scan(&isLike)
45 |
46 | if err == sql.ErrNoRows {
47 | return nil, nil // No reaction
48 | }
49 |
50 | if err != nil {
51 | return nil, err
52 | }
53 |
54 | if !isLike.Valid {
55 | return nil, nil
56 | }
57 |
58 | value := isLike.Bool
59 | return &value, nil
60 | }
61 |
62 | func (r *ReactionRepository) GetReactionCounts(postId int) (model.ReactionCounts, error) {
63 | counts := model.ReactionCounts{}
64 |
65 | err := r.Repository.db.QueryRow(
66 | "SELECT COUNT(*) FROM post_reactions WHERE post_id = $1 AND is_like = TRUE",
67 | postId,
68 | ).Scan(&counts.Likes)
69 |
70 | if err != nil {
71 | return counts, err
72 | }
73 |
74 | err = r.Repository.db.QueryRow(
75 | "SELECT COUNT(*) FROM post_reactions WHERE post_id = $1 AND is_like = FALSE",
76 | postId,
77 | ).Scan(&counts.Dislikes)
78 |
79 | return counts, err
80 | }
81 |
82 | func (r *ReactionRepository) GetReactionsForPosts(userId int, postIds []int) (map[int]model.ReactionCounts, error) {
83 | if len(postIds) == 0 {
84 | return make(map[int]model.ReactionCounts), nil
85 | }
86 |
87 | // Initialize result map
88 | result := make(map[int]model.ReactionCounts)
89 |
90 | // Generate ? placeholders
91 | placeholders := make([]string, len(postIds))
92 | args := make([]interface{}, len(postIds))
93 | for i, id := range postIds {
94 | args[i] = id
95 | placeholders[i] = "?"
96 | }
97 | inClause := strings.Join(placeholders, ",")
98 |
99 | // Get all likes and dislikes counts
100 | query := `
101 | SELECT post_id,
102 | SUM(CASE WHEN is_like = TRUE THEN 1 ELSE 0 END) as likes,
103 | SUM(CASE WHEN is_like = FALSE THEN 1 ELSE 0 END) as dislikes
104 | FROM post_reactions
105 | WHERE post_id IN (` + inClause + `)
106 | GROUP BY post_id
107 | `
108 | rows, err := r.Repository.db.Query(query, args...)
109 | if err != nil {
110 | return nil, err
111 | }
112 | defer rows.Close()
113 |
114 | for rows.Next() {
115 | var postId, likes, dislikes int
116 | if err := rows.Scan(&postId, &likes, &dislikes); err != nil {
117 | return nil, err
118 | }
119 |
120 | result[postId] = model.ReactionCounts{
121 | Likes: likes,
122 | Dislikes: dislikes,
123 | }
124 | }
125 |
126 | // Get user's own reactions
127 | if userId > 0 {
128 | query = `
129 | SELECT post_id, is_like
130 | FROM post_reactions
131 | WHERE user_id = $1 AND post_id IN (` + inClause + `)
132 | `
133 |
134 | args = append([]interface{}{userId}, args...)
135 |
136 | rows, err := r.Repository.db.Query(query, args...)
137 | if err != nil {
138 | return nil, err
139 | }
140 | defer rows.Close()
141 |
142 | for rows.Next() {
143 | var postId int
144 | var isLike sql.NullBool
145 |
146 | if err := rows.Scan(&postId, &isLike); err != nil {
147 | return nil, err
148 | }
149 |
150 | counts, exists := result[postId]
151 | if !exists {
152 | counts = model.ReactionCounts{}
153 | }
154 |
155 | if isLike.Valid {
156 | value := isLike.Bool
157 | counts.UserReaction = &value
158 | }
159 |
160 | result[postId] = counts
161 | }
162 | }
163 |
164 | // Initialize counts for posts without reactions
165 | for _, postId := range postIds {
166 | if _, exists := result[postId]; !exists {
167 | result[postId] = model.ReactionCounts{
168 | Likes: 0,
169 | Dislikes: 0,
170 | }
171 | }
172 | }
173 |
174 | return result, nil
175 | }
176 |
--------------------------------------------------------------------------------
/backend/pkg/controller/app.go:
--------------------------------------------------------------------------------
1 | package app
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "log"
7 | "net/http"
8 | "social-network/pkg/model/request"
9 | "social-network/pkg/model/response"
10 | "social-network/pkg/repository"
11 | "sync"
12 | "time"
13 |
14 | "slices"
15 |
16 | "github.com/gorilla/websocket"
17 | )
18 |
19 | type App struct {
20 | repository repository.Repository
21 | clients map[int][]*Client
22 | mu sync.Mutex
23 | upgrader *websocket.Upgrader
24 | }
25 |
26 | type Client struct {
27 | Session string
28 | ActiveTime time.Time
29 | UserId int
30 | Connection *websocket.Conn
31 | }
32 |
33 | func NewApp(repository *repository.Repository) *App {
34 | upgrader := &websocket.Upgrader{
35 | CheckOrigin: func(r *http.Request) bool {
36 | return true
37 | },
38 | ReadBufferSize: 1024,
39 | WriteBufferSize: 1024,
40 | }
41 |
42 | return &App{
43 | repository: *repository,
44 | upgrader: upgrader,
45 | clients: make(map[int][]*Client),
46 | }
47 | }
48 |
49 | func (app *App) ServeError(w http.ResponseWriter, err *response.Error) {
50 | fmt.Println("Error:", err.Cause)
51 | status, body := response.Marshal(err)
52 | w.Header().Set("Content-Type", "application/json")
53 | w.WriteHeader(status)
54 | w.Write(body)
55 | }
56 |
57 | func (app *App) addClient(userid int, session string, client *websocket.Conn) *Client {
58 | app.mu.Lock()
59 | defer app.mu.Unlock()
60 | c := &Client{UserId: userid, Session: session, ActiveTime: time.Now(), Connection: client}
61 | app.clients[userid] = append(app.clients[userid], c)
62 | return c
63 | }
64 |
65 | func (app *App) removeClient(client *Client) {
66 | app.mu.Lock()
67 | defer app.mu.Unlock()
68 |
69 | for i, c := range app.clients[client.UserId] {
70 | if c == client {
71 | app.clients[client.UserId] = slices.Delete(app.clients[client.UserId], i, i+1)
72 | break
73 | }
74 | }
75 |
76 | if len(app.clients[client.UserId]) == 0 {
77 | delete(app.clients, client.UserId)
78 | }
79 | }
80 |
81 | func (app *App) readMessage(conn *websocket.Conn, client *Client) {
82 | for {
83 |
84 | _, payload, err := conn.ReadMessage()
85 | if err != nil {
86 | if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
87 | log.Printf("Error reading message: %v", err)
88 | }
89 | app.removeClient(client)
90 | break
91 | }
92 |
93 | reqType, reqBody, err := request.Unmarshal(payload)
94 |
95 | if err != nil {
96 | log.Println("Unmarshal payload err", err)
97 | return
98 | }
99 |
100 | session := app.GetReflectValue(reqBody, "Session", "string")
101 |
102 | fmt.Println("session", session)
103 | if session == nil || session.(string) != client.Session {
104 | app.ShowMessage(client, &response.Error{
105 | Code: 401,
106 | Cause: "unauthorized: invalid session111",
107 | })
108 | app.removeClient(client)
109 | break
110 | }
111 |
112 | uid, err := app.repository.Session().FindUserIDBySession(session.(string))
113 | if err != nil {
114 | app.ShowMessage(client, &response.Error{
115 | Code: 401,
116 | Cause: "unauthorized: invalid session",
117 | })
118 | app.removeClient(client)
119 | break
120 | }
121 | reqBody.Ctx = context.WithValue(context.Background(), "user_id", uid)
122 | switch reqType {
123 | case "update-seen-message":
124 | app.UpdateSeenMessageWS(reqBody)
125 | case "add-message":
126 | response, err := app.AddMessage(reqBody)
127 | if err != nil {
128 | app.ShowMessage(client, err)
129 | continue
130 | }
131 | app.SentToActiveRecipient(response)
132 | }
133 | }
134 | }
135 |
136 | func (app *App) SentToActiveRecipient(response *response.AddMessage) {
137 |
138 | senderId := response.Message.SenderId
139 | RecipientId := response.Message.RecipientId
140 | groupId := response.Message.GroupId
141 |
142 | for _, c := range app.clients[senderId] {
143 | response.Message.IsOwned = true
144 | app.ShowMessage(c, response)
145 | }
146 |
147 | if RecipientId != 0 {
148 | // Brodcast to RecipientId
149 | for _, c := range app.clients[RecipientId] {
150 | response.Message.IsOwned = false
151 | app.ShowMessage(c, response)
152 | }
153 | } else {
154 | // Brodcast to group members
155 | users, err := app.repository.Group().GetGroupMembers(groupId)
156 | response.Message.IsOwned = false
157 |
158 | if err != nil {
159 | fmt.Println("Error broadcasting to group members:", err)
160 | return
161 | }
162 |
163 | for _, u := range users {
164 | if u.ID != senderId {
165 | for _, c := range app.clients[u.ID] {
166 | response.Message.IsOwned = false
167 | app.ShowMessage(c, response)
168 | }
169 | }
170 | }
171 | }
172 | }
173 |
174 | func (app *App) ShowMessage(client *Client, res any) {
175 | if client == nil {
176 | return
177 | }
178 |
179 | _, data := response.Marshal(res)
180 |
181 | client.Connection.WriteMessage(websocket.TextMessage, data)
182 | }
183 |
--------------------------------------------------------------------------------
/backend/pkg/model/response/types.go:
--------------------------------------------------------------------------------
1 | package response
2 |
3 | import "social-network/pkg/model"
4 |
5 | type Login struct {
6 | Session string `json:"session"`
7 | UserId int `json:"user_id"`
8 | Username string `json:"username"`
9 | }
10 |
11 | type RegisterError struct {
12 | Error
13 | Field string `json:"field"`
14 | }
15 |
16 | type GetPosts struct {
17 | Posts []*model.Post `json:"posts"`
18 | Userid int `json:"userid"`
19 | }
20 |
21 | type GetPost struct {
22 | Userid int `json:"userid,omitempty"`
23 | Post *model.Post `json:"post,omitempty"`
24 | }
25 |
26 | type AddPost struct {
27 | Post *model.Post `json:"post,omitempty"`
28 | }
29 |
30 | type ReactToPost struct {
31 | Userid int `json:"userid"`
32 | Post *model.Post `json:"post,omitempty"`
33 | Success bool `json:"success"`
34 | }
35 |
36 | type GetPostShares struct {
37 | AllUsers []*model.User `json:"all_users"`
38 | Success bool `json:"success"`
39 | }
40 |
41 | type AddPostShare struct {
42 | Message string `json:"message"`
43 | Success bool `json:"success"`
44 | }
45 |
46 | type RemovePostShare struct {
47 | Message string `json:"message"`
48 | Success bool `json:"success"`
49 | }
50 |
51 | type GetProfile struct {
52 | User *model.User `json:"user"`
53 | CurrentUser *model.User `json:"currentUser,omitempty"`
54 | }
55 |
56 | type GetProfiles struct {
57 | FollowRequests []*model.User `json:"followRequests"`
58 | AllUsers []*model.User `json:"allUsers"`
59 | CurrentUser *model.User `json:"currentUser"`
60 | }
61 |
62 | type SetProfilePrivacy struct {
63 | Success bool `json:"success"`
64 | }
65 |
66 | type AddGroupComment struct {
67 | Post *model.Post `json:"post"`
68 | }
69 |
70 | type GetGroupComments struct {
71 | Post *model.Post `json:"post"`
72 | }
73 |
74 | type ReactToGroupPost struct {
75 | Post *model.Post `json:"post"`
76 | }
77 |
78 | type GetGroupPost struct {
79 | Post *model.Post `json:"post"`
80 | }
81 |
82 | type GetAllNotifications struct {
83 | Notifications map[string]int `json:"notifications"`
84 | TotalCount int `json:"totalCount"`
85 | }
86 |
87 | type CheckNewFollowNotification struct {
88 | HasNewFollow bool `json:"hasNewFollow"`
89 | NewFollowers []*model.User `json:"newFollowers"`
90 | }
91 |
92 | type DeleteFollowNotification struct {
93 | Message string `json:"message"`
94 | }
95 |
96 | type DeleteNewEventNotification struct {
97 | Message string `json:"message"`
98 | }
99 |
100 | type CreateGroup struct {
101 | Success bool `json:"success"`
102 | Group *model.Group `json:"group,omitempty"`
103 | }
104 |
105 | type GetGroups struct {
106 | GroupInvites []*model.Group `json:"groupInvites"`
107 | JoinRequests []*model.Group `json:"joinRequests"`
108 | All []*model.Group `json:"all"`
109 | }
110 |
111 | type GetGroupData struct {
112 | Group *model.Group `json:"group"`
113 | }
114 |
115 | type AddGroupPost struct {
116 | Post *model.Post `json:"post"`
117 | }
118 |
119 | type AddGroupEvent struct {
120 | Success bool `json:"success"`
121 | Event *model.GroupEvent `json:"event,omitempty"`
122 | }
123 |
124 | type GetGroupInviteUsers struct {
125 | Users []*model.User `json:"users"`
126 | }
127 |
128 | type InviteUserToGroup struct {
129 | Success bool `json:"success"`
130 | Message string `json:"message,omitempty"`
131 | }
132 |
133 | type RespondToGroupInvitation struct {
134 | Success bool `json:"success"`
135 | Message string `json:"message,omitempty"`
136 | }
137 |
138 | type AddEventOption struct {
139 | Option *model.EventOption `json:"option"`
140 | }
141 |
142 | type RequestJoinGroup struct {
143 | Success bool `json:"success"`
144 | Message string `json:"message,omitempty"`
145 | }
146 |
147 | type GetJoinRequestCount struct {
148 | Count int `json:"count"`
149 | }
150 |
151 | type RespondToJoinRequest struct {
152 | Success bool `json:"success"`
153 | Message string `json:"message,omitempty"`
154 | }
155 |
156 | type GetUnreadMessagesCount struct {
157 | Count int `json:"count"`
158 | }
159 |
160 | type RequestFollow struct {
161 | Success bool `json:"success"`
162 | Message string `json:"message,omitempty"`
163 | }
164 |
165 | type AcceptFollow struct {
166 | Success bool `json:"success"`
167 | Message string `json:"message,omitempty"`
168 | }
169 |
170 | type DeleteFollow struct {
171 | Success bool `json:"success"`
172 | Message string `json:"message,omitempty"`
173 | }
174 |
175 | type GetChat struct {
176 | PrivateConvs []*model.Conv `json:"privateConvs"`
177 | GroupConvs []*model.Conv `json:"groupConvs"`
178 | NewConvs []*model.Conv `json:"newConvs"`
179 | }
180 |
181 | type GetMessages struct {
182 | Messages []*model.Message `json:"messages"`
183 | }
184 |
185 | type AddMessage struct {
186 | Type string `json:"type"`
187 | Message *model.Message `json:"message"`
188 | IsGroup bool `json:"isGroup"`
189 | }
190 |
191 | type Logout struct {
192 | Message string `json:"message"`
193 | }
194 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Social Network Project
2 |
3 | ## Description
4 | This project involves building a Facebook-like social network that provides the following features:
5 |
6 | - User authentication with sessions and cookies
7 | - Follower system with public and private profiles
8 | - Posts with privacy settings and multimedia support
9 | - Group creation, membership management, and events
10 | - Real-time chat using WebSockets
11 | - Notifications for various actions and events
12 |
13 | Both the backend and frontend are containerized using Docker to ensure easy deployment and maintainability.
14 |
15 | ---
16 |
17 | ## Features
18 |
19 | ### Authentication
20 | - Registration with the following fields:
21 | - **Mandatory:** Email, Password, First Name, Last Name, Date of Birth
22 | - **Optional:** Avatar/Image, Nickname, About Me
23 | - Login with session persistence using cookies
24 | - Logout functionality
25 |
26 | ### Followers
27 | - Follow/unfollow users
28 | - Handle follow requests for private profiles
29 | - Automatic follow for public profiles
30 |
31 | ### Profiles
32 | - Display user information, activity, and posts
33 | - Show followers and following users
34 | - Toggle between public and private profiles
35 |
36 | ### Posts
37 | - Create posts with images or GIFs
38 | - Comment on posts
39 | - Privacy settings:
40 | - Public
41 | - Almost private (followers only)
42 | - Private (specific followers only)
43 |
44 | ### Groups
45 | - Create and manage groups with title and description
46 | - Invite members and accept/decline requests
47 | - Browse and request to join groups
48 | - Group-specific posts and comments
49 | - Event creation with RSVP options (e.g., "Going" or "Not Going")
50 |
51 | ### Chat
52 | - Real-time private messaging between users (via WebSockets)
53 | - Group chat rooms
54 | - Emoji support
55 |
56 | ### Notifications
57 | - Notifications for:
58 | - Follow requests
59 | - Group invitations
60 | - Requests to join a group
61 | - New group events
62 | - Separate UI for private messages and notifications
63 |
64 | ---
65 |
66 | ## Technologies Used
67 |
68 | ### Frontend
69 | - **Languages:** HTML, CSS, JavaScript
70 | - **Frameworks:** (Choose one: Next.js, Vue.js, Svelte, Mithril, etc.)
71 |
72 | ### Backend
73 | - **Language:** Go
74 | - **Database:** SQLite
75 | - **Packages:**
76 | - `golang-migrate` for migrations
77 | - `bcrypt` for password hashing
78 | - `gorilla/websocket` for real-time communication
79 | - `uuid` for generating unique IDs
80 | - **Web Server:** Caddy (or custom-built server)
81 |
82 | ### Containerization
83 | - Docker images for frontend and backend
84 |
85 | ---
86 |
87 | ## Folder Structure
88 |
89 | ```
90 | .
91 | ├── backend
92 | │ ├── pkg
93 | │ │ ├── db
94 | │ │ │ ├── migrations
95 | │ │ │ │ ├── 000001_create_users_table.up.sql
96 | │ │ │ │ ├── 000001_create_users_table.down.sql
97 | │ │ │ │ ├── 000002_create_posts_table.up.sql
98 | │ │ │ │ └── 000002_create_posts_table.down.sql
99 | │ │ └── sqlite
100 | │ │ └── sqlite.go
101 | │ └── server.go
102 | ├── frontend
103 | │ ├── public
104 | │ │ └── index.html
105 | │ └── src
106 | │ ├── components
107 | │ ├── styles
108 | │ └── main.js
109 | ├── docker-compose.yml
110 | └── README.md
111 | ```
112 |
113 | ---
114 |
115 | ## Installation and Setup
116 |
117 | ### Prerequisites
118 | - Docker and Docker Compose installed on your machine
119 | - Go programming environment
120 |
121 | ### Clone the Repository
122 | ```bash
123 | git clone https://github.com/your-repo/social-network.git
124 | cd social-network
125 | ```
126 |
127 | ### Backend Setup
128 | 1. Navigate to the `backend` folder:
129 | ```bash
130 | cd backend
131 | ```
132 | 2. Build the Docker image:
133 | ```bash
134 | docker build -t social-network-backend .
135 | ```
136 | 3. Run the backend container:
137 | ```bash
138 | docker run -p 8080:8080 social-network-backend
139 | ```
140 |
141 | ### Frontend Setup
142 | 1. Navigate to the `frontend` folder:
143 | ```bash
144 | cd frontend
145 | ```
146 | 2. Build the Docker image:
147 | ```bash
148 | docker build -t social-network-frontend .
149 | ```
150 | 3. Run the frontend container:
151 | ```bash
152 | docker run -p 3000:3000 social-network-frontend
153 | ```
154 |
155 | ### Database Setup
156 | 1. Apply migrations:
157 | ```bash
158 | go run backend/pkg/db/sqlite/sqlite.go
159 | ```
160 |
161 | ---
162 |
163 | ## Usage
164 | 1. Open the frontend in your browser at `http://localhost:3000`.
165 | 2. Register a new user and log in.
166 | 3. Explore features like creating posts, following users, and joining groups.
167 |
168 | ---
169 |
170 | ## Contribution Guidelines
171 | 1. Fork the repository.
172 | 2. Create a feature branch:
173 | ```bash
174 | git checkout -b feature-name
175 | ```
176 | 3. Commit your changes:
177 | ```bash
178 | git commit -m "Add new feature"
179 | ```
180 | 4. Push to the branch:
181 | ```bash
182 | git push origin feature-name
183 | ```
184 | 5. Submit a pull request.
185 |
186 | ---
187 |
188 | ## License
189 | This project is licensed under the MIT License. See the `LICENSE` file for details.
190 |
191 | ---
192 |
193 | ## Contact
194 | For questions or suggestions, please contact [zone01@gmail.com].
195 |
--------------------------------------------------------------------------------
/frontend/components/create-event-modal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import type React from "react";
4 | import { useState } from "react";
5 |
6 | interface CreateEventModalProps {
7 | onClose: () => void;
8 | onSubmit: (event: {
9 | title: string;
10 | description: string;
11 | option1: string;
12 | option2: string;
13 | date: string;
14 | place: string;
15 | }) => void;
16 | }
17 |
18 | const CreateEventModal: React.FC = ({
19 | onClose,
20 | onSubmit,
21 | }) => {
22 | const [title, setTitle] = useState("");
23 | const [description, setDescription] = useState("");
24 | const [option1, setOption1] = useState("");
25 | const [option2, setOption2] = useState("");
26 | const [date, setDate] = useState("");
27 | const [place, setPlace] = useState("");
28 |
29 | const handleSubmit = (e: React.FormEvent) => {
30 | e.preventDefault();
31 | onSubmit({
32 | title,
33 | description,
34 | option1,
35 | option2,
36 | date,
37 | place,
38 | });
39 | };
40 |
41 | return (
42 |
43 |
e.stopPropagation()}>
44 |
45 |
Create New Event
46 |
49 |
50 |
51 |
153 |
154 |
155 | );
156 | };
157 | export default CreateEventModal;
158 |
--------------------------------------------------------------------------------
/frontend/components/group-invite-modal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState, useEffect, useCallback } from "react";
4 | import Image from "next/image";
5 | // import getAvailableUsersToInvite from "@/api/pass-groups/pass-getAvailableUsersToInvite";
6 | // import inviteUserToGroup from "@/api/pass-groups/pass-inviteUserToGroup";
7 | // import "./group-invite-modal.css";
8 | import { useGlobalAPIHelper } from "@/helpers/GlobalAPIHelper";
9 |
10 | interface User {
11 | id: number;
12 | firstname: string;
13 | lastname: string;
14 | nickname: string;
15 | avatar: string;
16 | }
17 |
18 | interface GroupInviteModalProps {
19 | groupId: number;
20 | isOpen: boolean;
21 | onClose: () => void;
22 | onInviteSent?: () => void;
23 | }
24 |
25 | export default function GroupInviteModal({
26 | groupId,
27 | isOpen,
28 | onClose,
29 | onInviteSent,
30 | }: GroupInviteModalProps) {
31 | const [availableUsers, setAvailableUsers] = useState([]);
32 | const [isLoading, setIsLoading] = useState(false);
33 | const [invitingUsers, setInvitingUsers] = useState>(new Set());
34 | const { apiCall } = useGlobalAPIHelper();
35 |
36 | const loadAvailableUsers = useCallback(async () => {
37 | try {
38 | setIsLoading(true);
39 | const data = await apiCall(
40 | {
41 | type: "get-group-invite-users",
42 | data: { GroupId: groupId },
43 | },
44 | "POST",
45 | "getGroupInviteUsers"
46 | );
47 |
48 | console.log("data", data);
49 | if (data.error) {
50 | console.error("Error loading users:", data.error);
51 | setAvailableUsers([]);
52 | return;
53 | }
54 |
55 | setAvailableUsers(data.users || []);
56 | } catch (error) {
57 | console.error("Error loading available users:", error);
58 | setAvailableUsers([]);
59 | } finally {
60 | setIsLoading(false);
61 | }
62 | }, [groupId, apiCall]);
63 |
64 | useEffect(() => {
65 | if (isOpen) {
66 | loadAvailableUsers();
67 | }
68 | }, [isOpen, groupId, loadAvailableUsers]);
69 |
70 | const handleInviteUser = async (userId: number) => {
71 | if (invitingUsers.has(userId)) return;
72 |
73 | try {
74 | setInvitingUsers((prev) => new Set(prev).add(userId));
75 | const data = await apiCall(
76 | {
77 | type: "invite-user-to-group",
78 | data: { GroupId: groupId, UserId: userId },
79 | },
80 | "POST",
81 | "inviteUserToGroup"
82 | );
83 |
84 | if (data.error) {
85 | return;
86 | }
87 |
88 | // Remove user from available users after successful invite
89 | setAvailableUsers((prev) => prev.filter((user) => user.id !== userId));
90 |
91 | if (onInviteSent) {
92 | onInviteSent();
93 | }
94 | } catch (error) {
95 | console.error("Error inviting user:", error);
96 | alert("Failed to send invitation");
97 | } finally {
98 | setInvitingUsers((prev) => {
99 | const newSet = new Set(prev);
100 | newSet.delete(userId);
101 | return newSet;
102 | });
103 | }
104 | };
105 |
106 | if (!isOpen) return null;
107 |
108 | return (
109 |
110 |
e.stopPropagation()}>
111 |
112 |
Invite Users to Group
113 |
116 |
117 |
118 |
119 | {isLoading ? (
120 |
Loading available users...
121 | ) : (
122 |
123 | {availableUsers.length === 0 ? (
124 |
No users available to invite
125 | ) : (
126 | availableUsers.map((user) => (
127 |
128 |
129 |
143 |
144 |
145 | {user.firstname} {user.lastname}
146 |
147 | {user.nickname && (
148 |
@{user.nickname}
149 | )}
150 |
151 |
152 |
159 |
160 | ))
161 | )}
162 |
163 | )}
164 |
165 |
166 |
167 | );
168 | }
169 |
--------------------------------------------------------------------------------
/frontend/components/comment-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import type React from "react";
4 | import { useState, useRef } from "react";
5 | import Image from "next/image";
6 | // import addComment from "@/api/posts/addComment";
7 | import { uploadFile } from "@/helpers/uploadFile";
8 | // import Popup from "@/home/home/popup";
9 | import { useGlobalAPIHelper } from "@/helpers/GlobalAPIHelper";
10 |
11 | interface CommentFormProps {
12 | postId: number;
13 | onCommentAdded: () => void;
14 | disabled?: boolean;
15 | }
16 |
17 | export default function CommentForm({
18 | postId,
19 | onCommentAdded,
20 | disabled,
21 | }: CommentFormProps) {
22 | const [newComment, setNewComment] = useState("");
23 | const [selectedImage, setSelectedImage] = useState(null);
24 | const [imagePreview, setImagePreview] = useState(null);
25 | const [isSubmitting, setIsSubmitting] = useState(false);
26 | const fileInputRef = useRef(null);
27 | const { apiCall } = useGlobalAPIHelper();
28 |
29 | const handleImageSelect = (e: React.ChangeEvent) => {
30 | const file = e.target.files?.[0];
31 | if (file) {
32 | // Validate file type
33 | if (!file.type.startsWith("image/")) {
34 | return;
35 | }
36 |
37 | // Validate file size (10MB limit)
38 | if (file.size > 10 * 1024 * 1024) {
39 | return;
40 | }
41 |
42 | setSelectedImage(file);
43 |
44 | // Create preview
45 | const reader = new FileReader();
46 | reader.onload = (e) => {
47 | setImagePreview(e.target?.result as string);
48 | };
49 | reader.readAsDataURL(file);
50 | }
51 | };
52 |
53 | const removeImage = () => {
54 | setSelectedImage(null);
55 | setImagePreview(null);
56 | if (fileInputRef.current) {
57 | fileInputRef.current.value = "";
58 | }
59 | };
60 |
61 | const handleSubmit = async (e: React.FormEvent) => {
62 | e.preventDefault();
63 |
64 | if (newComment.trim() === "" && !selectedImage) return;
65 | if (isSubmitting) return;
66 |
67 | setIsSubmitting(true);
68 |
69 | try {
70 | let filename = "";
71 |
72 | if (selectedImage) {
73 | const formData = new FormData();
74 | formData.append("file", selectedImage);
75 |
76 | filename = await uploadFile(formData, "post-comments");
77 | }
78 |
79 | // Add comment
80 | await apiCall(
81 | {
82 | type: "add-comment",
83 | data: {
84 | postId,
85 | text: newComment.trim(),
86 | image: filename || "",
87 | },
88 | },
89 | "POST",
90 | "addComment"
91 | );
92 |
93 | // Reset form
94 | setNewComment("");
95 | setSelectedImage(null);
96 | setImagePreview(null);
97 | if (fileInputRef.current) {
98 | fileInputRef.current.value = "";
99 | }
100 |
101 | // Notify parent component
102 | onCommentAdded();
103 | } catch (error) {
104 | console.log(error);
105 | } finally {
106 | setIsSubmitting(false);
107 | }
108 | };
109 |
110 | return (
111 |
187 | );
188 | }
189 |
--------------------------------------------------------------------------------
/frontend/components/create-group-modal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import type React from "react";
4 | import { useState } from "react";
5 | import { uploadFile } from "@/helpers/uploadFile";
6 | import Image from "next/image";
7 |
8 | interface CreateGroupModalProps {
9 | onClose: () => void;
10 | onSubmit: (group: {
11 | image: string;
12 | title: string;
13 | description: string;
14 | }) => void;
15 | }
16 |
17 | const CreateGroupModal: React.FC = ({
18 | onClose,
19 | onSubmit,
20 | }) => {
21 | const [title, setTitle] = useState("");
22 | const [description, setDescription] = useState("");
23 | const [imagePreview, setImagePreview] = useState(null);
24 | const [image, setImage] = useState(null);
25 | const [disableButton, setDisableButton] = useState(false);
26 | const [errors, setErrors] = useState<{
27 | title?: string;
28 | description?: string;
29 | }>({});
30 |
31 | const titleRegex = /^[a-zA-Z0-9\s\-]{3,50}$/;
32 |
33 | const validateInputs = () => {
34 | const newErrors: { title?: string; description?: string } = {};
35 |
36 | if (!title.trim()) {
37 | newErrors.title = "Group title is required.";
38 | } else if (!titleRegex.test(title)) {
39 | newErrors.title =
40 | "Title must be 3-50 characters and contain only letters, numbers, spaces, or dashes.";
41 | }
42 |
43 | if (description.length > 300) {
44 | newErrors.description = "Description must be 300 characters or less.";
45 | }
46 |
47 | setErrors(newErrors);
48 | return Object.keys(newErrors).length === 0;
49 | };
50 |
51 | const handleImageChange = (e: React.ChangeEvent) => {
52 | const file = e.target.files?.[0];
53 | if (file) {
54 | const reader = new FileReader();
55 | reader.onloadend = () => {
56 | setImagePreview(reader.result as string);
57 | };
58 | reader.readAsDataURL(file);
59 | setImage(file);
60 | }
61 | };
62 |
63 | const handleSubmit = async (e: React.FormEvent) => {
64 | e.preventDefault();
65 | if (!validateInputs()) return;
66 |
67 | setDisableButton(true);
68 | try {
69 | let imageUrl = "";
70 |
71 | if (image) {
72 | const formData = new FormData();
73 | formData.append("file", image);
74 | imageUrl = await uploadFile(formData, "/avatars");
75 | }
76 |
77 | onSubmit({
78 | image: imageUrl,
79 | title,
80 | description,
81 | });
82 | } catch (err) {
83 | console.error(err);
84 | } finally {
85 | setDisableButton(false);
86 | }
87 | };
88 |
89 | return (
90 |
91 |
e.stopPropagation()}>
92 |
93 |
Create New Group
94 |
97 |
98 |
99 |
180 |
181 |
182 | );
183 | };
184 |
185 | export default CreateGroupModal;
186 |
--------------------------------------------------------------------------------
/frontend/components/create-post-modal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import type React from "react";
4 | import { useState } from "react";
5 | import { uploadFile } from "@/helpers/uploadFile";
6 | import Image from "next/image";
7 |
8 | interface CreatePostModalProps {
9 | onClose: () => void;
10 | onSubmit: (post: {
11 | image: string;
12 | caption: string;
13 | privacy?: string;
14 | groupId?: number;
15 | }) => void;
16 | groupId?: number;
17 | }
18 |
19 | const CreatePostModal: React.FC = ({
20 | onClose,
21 | onSubmit,
22 | groupId,
23 | }) => {
24 | const [caption, setCaption] = useState("");
25 | const [privacy, setPrivacy] = useState("public");
26 | const [imagePreview, setImagePreview] = useState(null);
27 | const [image, setImage] = useState(null);
28 | const [isSubmitting, setIsSubmitting] = useState(false);
29 | // const [popup, setPopup] = useState<{
30 | // message: string;
31 | // status: "success" | "failure";
32 | // } | null>(null);
33 |
34 | const handleImageChange = (e: React.ChangeEvent) => {
35 | const file = e.target.files?.[0];
36 | if (file) {
37 | const reader = new FileReader();
38 | reader.onloadend = () => {
39 | setImagePreview(reader.result as string);
40 | };
41 | reader.readAsDataURL(file);
42 | setImage(file);
43 | }
44 | };
45 |
46 | const handleSubmit = async (e: React.FormEvent) => {
47 | e.preventDefault();
48 | setIsSubmitting(true);
49 |
50 | try {
51 | let imageUrl = "";
52 |
53 | if (image) {
54 | const formData = new FormData();
55 | formData.append("file", image);
56 | imageUrl = await uploadFile(
57 | formData,
58 | groupId ? "/group-posts" : "/posts"
59 | );
60 | }
61 |
62 | const data = groupId
63 | ? {
64 | image: imageUrl,
65 | caption,
66 | groupId,
67 | }
68 | : {
69 | image: imageUrl,
70 | caption,
71 | privacy,
72 | };
73 |
74 | onSubmit(data);
75 | } catch (err) {
76 | // setPopup({ message: "Failed to load comments.", status: "failure" });
77 |
78 | console.error(err);
79 | } finally {
80 | setIsSubmitting(false);
81 | }
82 | };
83 |
84 | return (
85 |
86 |
e.stopPropagation()}>
87 |
88 |
Create New Post
89 |
92 |
93 |
94 |
178 |
179 | {/* {popup && (
180 |
setPopup(null)}
184 | />
185 | )} */}
186 |
187 | );
188 | };
189 |
190 | export default CreatePostModal;
191 |
--------------------------------------------------------------------------------