Compare commits
No commits in common. "602a13934999f024245aff183b76716b3a6733e5" and "6cb0a921c96d8a0186879123422e63d8d6975455" have entirely different histories.
602a139349
...
6cb0a921c9
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -26,8 +26,6 @@ authorized_keys
|
||||||
|
|
||||||
# Platform / Auth Secrets
|
# Platform / Auth Secrets
|
||||||
firebase-auth-*.json
|
firebase-auth-*.json
|
||||||
*service-account*.json
|
|
||||||
go-backend/firebase-service-account.json
|
|
||||||
google-services.json
|
google-services.json
|
||||||
GoogleService-Info.plist
|
GoogleService-Info.plist
|
||||||
*credentials.json
|
*credentials.json
|
||||||
|
|
@ -66,8 +64,6 @@ desktop.ini
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
*.tar
|
*.tar
|
||||||
*.gz
|
*.gz
|
||||||
*.backup
|
|
||||||
*.orig
|
|
||||||
*.exe
|
*.exe
|
||||||
*.bin
|
*.bin
|
||||||
*.db
|
*.db
|
||||||
|
|
@ -121,9 +117,6 @@ debug/
|
||||||
*.g.dart
|
*.g.dart
|
||||||
*.freezed.dart
|
*.freezed.dart
|
||||||
|
|
||||||
# Service account credentials
|
|
||||||
.env_files/
|
|
||||||
|
|
||||||
# Project Specific Exclusions
|
# Project Specific Exclusions
|
||||||
logo.ai
|
logo.ai
|
||||||
sojorn_app/analysis_results_final.txt
|
sojorn_app/analysis_results_final.txt
|
||||||
|
|
@ -137,13 +130,9 @@ go-backend/verify*
|
||||||
go-backend/migrate*
|
go-backend/migrate*
|
||||||
go-backend/fixdb*
|
go-backend/fixdb*
|
||||||
go-backend/api.exe
|
go-backend/api.exe
|
||||||
go-backend/scripts/run_migration.go
|
|
||||||
temp_server.env
|
temp_server.env
|
||||||
*.txt.bak
|
*.txt.bak
|
||||||
|
|
||||||
# Non-public staging area (kept local only)
|
|
||||||
_private/
|
|
||||||
|
|
||||||
# Miscellaneous Security
|
# Miscellaneous Security
|
||||||
*.history
|
*.history
|
||||||
*.bash_history
|
*.bash_history
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
---
|
|
||||||
trigger: manual
|
|
||||||
---
|
|
||||||
|
|
||||||
ALWAYS EDIT FILES LOCALLY, SYNC TO THE GIT, THEN PULL ON THE SERVER - SSH MPLS - AND BUILD.
|
|
||||||
661
LICENSE
661
LICENSE
|
|
@ -1,661 +0,0 @@
|
||||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
|
||||||
Version 3, 19 November 2007
|
|
||||||
|
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
|
||||||
of this license document, but changing it is not allowed.
|
|
||||||
|
|
||||||
Preamble
|
|
||||||
|
|
||||||
The GNU Affero General Public License is a free, copyleft license for
|
|
||||||
software and other kinds of works, specifically designed to ensure
|
|
||||||
cooperation with the community in the case of network server software.
|
|
||||||
|
|
||||||
The licenses for most software and other practical works are designed
|
|
||||||
to take away your freedom to share and change the works. By contrast,
|
|
||||||
our General Public Licenses are intended to guarantee your freedom to
|
|
||||||
share and change all versions of a program--to make sure it remains free
|
|
||||||
software for all its users.
|
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not
|
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
|
||||||
have the freedom to distribute copies of free software (and charge for
|
|
||||||
them if you wish), that you receive source code or can get it if you
|
|
||||||
want it, that you can change the software or use pieces of it in new
|
|
||||||
free programs, and that you know you can do these things.
|
|
||||||
|
|
||||||
Developers that use our General Public Licenses protect your rights
|
|
||||||
with two steps: (1) assert copyright on the software, and (2) offer
|
|
||||||
you this License which gives you legal permission to copy, distribute
|
|
||||||
and/or modify the software.
|
|
||||||
|
|
||||||
A secondary benefit of defending all users' freedom is that
|
|
||||||
improvements made in alternate versions of the program, if they
|
|
||||||
receive widespread use, become available for other developers to
|
|
||||||
incorporate. Many developers of free software are heartened and
|
|
||||||
encouraged by the resulting cooperation. However, in the case of
|
|
||||||
software used on network servers, this result may fail to come about.
|
|
||||||
The GNU General Public License permits making a modified version and
|
|
||||||
letting the public access it on a server without ever releasing its
|
|
||||||
source code to the public.
|
|
||||||
|
|
||||||
The GNU Affero General Public License is designed specifically to
|
|
||||||
ensure that, in such cases, the modified source code becomes available
|
|
||||||
to the community. It requires the operator of a network server to
|
|
||||||
provide the source code of the modified version running there to the
|
|
||||||
users of that server. Therefore, public use of a modified version, on
|
|
||||||
a publicly accessible server, gives the public access to the source
|
|
||||||
code of the modified version.
|
|
||||||
|
|
||||||
An older license, called the Affero General Public License and
|
|
||||||
published by Affero, was designed to accomplish similar goals. This is
|
|
||||||
a different license, not a version of the Affero GPL, but Affero has
|
|
||||||
released a new version of the Affero GPL which permits relicensing under
|
|
||||||
this license.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
|
||||||
modification follow.
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
0. Definitions.
|
|
||||||
|
|
||||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
|
||||||
|
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
|
||||||
works, such as semiconductor masks.
|
|
||||||
|
|
||||||
"The Program" refers to any copyrightable work licensed under this
|
|
||||||
License. Each licensee is addressed as "you". "Licensees" and
|
|
||||||
"recipients" may be individuals or organizations.
|
|
||||||
|
|
||||||
To "modify" a work means to copy from or adapt all or part of the work
|
|
||||||
in a fashion requiring copyright permission, other than the making of an
|
|
||||||
exact copy. The resulting work is called a "modified version" of the
|
|
||||||
earlier work or a work "based on" the earlier work.
|
|
||||||
|
|
||||||
A "covered work" means either the unmodified Program or a work based
|
|
||||||
on the Program.
|
|
||||||
|
|
||||||
To "propagate" a work means to do anything with it that, without
|
|
||||||
permission, would make you directly or secondarily liable for
|
|
||||||
infringement under applicable copyright law, except executing it on a
|
|
||||||
computer or modifying a private copy. Propagation includes copying,
|
|
||||||
distribution (with or without modification), making available to the
|
|
||||||
public, and in some countries other activities as well.
|
|
||||||
|
|
||||||
To "convey" a work means any kind of propagation that enables other
|
|
||||||
parties to make or receive copies. Mere interaction with a user through
|
|
||||||
a computer network, with no transfer of a copy, is not conveying.
|
|
||||||
|
|
||||||
An interactive user interface displays "Appropriate Legal Notices"
|
|
||||||
to the extent that it includes a convenient and prominently visible
|
|
||||||
feature that (1) displays an appropriate copyright notice, and (2)
|
|
||||||
tells the user that there is no warranty for the work (except to the
|
|
||||||
extent that warranties are provided), that licensees may convey the
|
|
||||||
work under this License, and how to view a copy of this License. If
|
|
||||||
the interface presents a list of user commands or options, such as a
|
|
||||||
menu, a prominent item in the list meets this criterion.
|
|
||||||
|
|
||||||
1. Source Code.
|
|
||||||
|
|
||||||
The "source code" for a work means the preferred form of the work
|
|
||||||
for making modifications to it. "Object code" means any non-source
|
|
||||||
form of a work.
|
|
||||||
|
|
||||||
A "Standard Interface" means an interface that either is an official
|
|
||||||
standard defined by a recognized standards body, or, in the case of
|
|
||||||
interfaces specified for a particular programming language, one that
|
|
||||||
is widely used among developers working in that language.
|
|
||||||
|
|
||||||
The "System Libraries" of an executable work include anything, other
|
|
||||||
than the work as a whole, that (a) is included in the normal form of
|
|
||||||
packaging a Major Component, but which is not part of that Major
|
|
||||||
Component, and (b) serves only to enable use of the work with that
|
|
||||||
Major Component, or to implement a Standard Interface for which an
|
|
||||||
implementation is available to the public in source code form. A
|
|
||||||
"Major Component", in this context, means a major essential component
|
|
||||||
(kernel, window system, and so on) of the specific operating system
|
|
||||||
(if any) on which the executable work runs, or a compiler used to
|
|
||||||
produce the work, or an object code interpreter used to run it.
|
|
||||||
|
|
||||||
The "Corresponding Source" for a work in object code form means all
|
|
||||||
the source code needed to generate, install, and (for an executable
|
|
||||||
work) run the object code and to modify the work, including scripts to
|
|
||||||
control those activities. However, it does not include the work's
|
|
||||||
System Libraries, or general-purpose tools or generally available free
|
|
||||||
programs which are used unmodified in performing those activities but
|
|
||||||
which are not part of the work. For example, Corresponding Source
|
|
||||||
includes interface definition files associated with source files for
|
|
||||||
the work, and the source code for shared libraries and dynamically
|
|
||||||
linked subprograms that the work is specifically designed to require,
|
|
||||||
such as by intimate data communication or control flow between those
|
|
||||||
subprograms and other parts of the work.
|
|
||||||
|
|
||||||
The Corresponding Source need not include anything that users
|
|
||||||
can regenerate automatically from other parts of the Corresponding
|
|
||||||
Source.
|
|
||||||
|
|
||||||
The Corresponding Source for a work in source code form is that
|
|
||||||
same work.
|
|
||||||
|
|
||||||
2. Basic Permissions.
|
|
||||||
|
|
||||||
All rights granted under this License are granted for the term of
|
|
||||||
copyright on the Program, and are irrevocable provided the stated
|
|
||||||
conditions are met. This License explicitly affirms your unlimited
|
|
||||||
permission to run the unmodified Program. The output from running a
|
|
||||||
covered work is covered by this License only if the output, given its
|
|
||||||
content, constitutes a covered work. This License acknowledges your
|
|
||||||
rights of fair use or other equivalent, as provided by copyright law.
|
|
||||||
|
|
||||||
You may make, run and propagate covered works that you do not
|
|
||||||
convey, without conditions so long as your license otherwise remains
|
|
||||||
in force. You may convey covered works to others for the sole purpose
|
|
||||||
of having them make modifications exclusively for you, or provide you
|
|
||||||
with facilities for running those works, provided that you comply with
|
|
||||||
the terms of this License in conveying all material for which you do
|
|
||||||
not control copyright. Those thus making or running the covered works
|
|
||||||
for you must do so exclusively on your behalf, under your direction
|
|
||||||
and control, on terms that prohibit them from making any copies of
|
|
||||||
your copyrighted material outside their relationship with you.
|
|
||||||
|
|
||||||
Conveying under any other circumstances is permitted solely under
|
|
||||||
the conditions stated below. Sublicensing is not allowed; section 10
|
|
||||||
makes it unnecessary.
|
|
||||||
|
|
||||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
|
||||||
|
|
||||||
No covered work shall be deemed part of an effective technological
|
|
||||||
measure under any applicable law fulfilling obligations under article
|
|
||||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
|
||||||
similar laws prohibiting or restricting circumvention of such
|
|
||||||
measures.
|
|
||||||
|
|
||||||
When you convey a covered work, you waive any legal power to forbid
|
|
||||||
circumvention of technological measures to the extent such circumvention
|
|
||||||
is effected by exercising rights under this License with respect to
|
|
||||||
the covered work, and you disclaim any intention to limit operation or
|
|
||||||
modification of the work as a means of enforcing, against the work's
|
|
||||||
users, your or third parties' legal rights to forbid circumvention of
|
|
||||||
technological measures.
|
|
||||||
|
|
||||||
4. Conveying Verbatim Copies.
|
|
||||||
|
|
||||||
You may convey verbatim copies of the Program's source code as you
|
|
||||||
receive it, in any medium, provided that you conspicuously and
|
|
||||||
appropriately publish on each copy an appropriate copyright notice;
|
|
||||||
keep intact all notices stating that this License and any
|
|
||||||
non-permissive terms added in accord with section 7 apply to the code;
|
|
||||||
keep intact all notices of the absence of any warranty; and give all
|
|
||||||
recipients a copy of this License along with the Program.
|
|
||||||
|
|
||||||
You may charge any price or no price for each copy that you convey,
|
|
||||||
and you may offer support or warranty protection for a fee.
|
|
||||||
|
|
||||||
5. Conveying Modified Source Versions.
|
|
||||||
|
|
||||||
You may convey a work based on the Program, or the modifications to
|
|
||||||
produce it from the Program, in the form of source code under the
|
|
||||||
terms of section 4, provided that you also meet all of these conditions:
|
|
||||||
|
|
||||||
a) The work must carry prominent notices stating that you modified
|
|
||||||
it, and giving a relevant date.
|
|
||||||
|
|
||||||
b) The work must carry prominent notices stating that it is
|
|
||||||
released under this License and any conditions added under section
|
|
||||||
7. This requirement modifies the requirement in section 4 to
|
|
||||||
"keep intact all notices".
|
|
||||||
|
|
||||||
c) You must license the entire work, as a whole, under this
|
|
||||||
License to anyone who comes into possession of a copy. This
|
|
||||||
License will therefore apply, along with any applicable section 7
|
|
||||||
additional terms, to the whole of the work, and all its parts,
|
|
||||||
regardless of how they are packaged. This License gives no
|
|
||||||
permission to license the work in any other way, but it does not
|
|
||||||
invalidate such permission if you have separately received it.
|
|
||||||
|
|
||||||
d) If the work has interactive user interfaces, each must display
|
|
||||||
Appropriate Legal Notices; however, if the Program has interactive
|
|
||||||
interfaces that do not display Appropriate Legal Notices, your
|
|
||||||
work need not make them do so.
|
|
||||||
|
|
||||||
A compilation of a covered work with other separate and independent
|
|
||||||
works, which are not by their nature extensions of the covered work,
|
|
||||||
and which are not combined with it such as to form a larger program,
|
|
||||||
in or on a volume of a storage or distribution medium, is called an
|
|
||||||
"aggregate" if the compilation and its resulting copyright are not
|
|
||||||
used to limit the access or legal rights of the compilation's users
|
|
||||||
beyond what the individual works permit. Inclusion of a covered work
|
|
||||||
in an aggregate does not cause this License to apply to the other
|
|
||||||
parts of the aggregate.
|
|
||||||
|
|
||||||
6. Conveying Non-Source Forms.
|
|
||||||
|
|
||||||
You may convey a covered work in object code form under the terms
|
|
||||||
of sections 4 and 5, provided that you also convey the
|
|
||||||
machine-readable Corresponding Source under the terms of this License,
|
|
||||||
in one of these ways:
|
|
||||||
|
|
||||||
a) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by the
|
|
||||||
Corresponding Source fixed on a durable physical medium
|
|
||||||
customarily used for software interchange.
|
|
||||||
|
|
||||||
b) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by a
|
|
||||||
written offer, valid for at least three years and valid for as
|
|
||||||
long as you offer spare parts or customer support for that product
|
|
||||||
model, to give anyone who possesses the object code either (1) a
|
|
||||||
copy of the Corresponding Source for all the software in the
|
|
||||||
product that is covered by this License, on a durable physical
|
|
||||||
medium customarily used for software interchange, for a price no
|
|
||||||
more than your reasonable cost of physically performing this
|
|
||||||
conveying of source, or (2) access to copy the
|
|
||||||
Corresponding Source from a network server at no charge.
|
|
||||||
|
|
||||||
c) Convey individual copies of the object code with a copy of the
|
|
||||||
written offer to provide the Corresponding Source. This
|
|
||||||
alternative is allowed only occasionally and noncommercially, and
|
|
||||||
only if you received the object code with such an offer, in accord
|
|
||||||
with subsection 6b.
|
|
||||||
|
|
||||||
d) Convey the object code by offering access from a designated
|
|
||||||
place (gratis or for a charge), and offer equivalent access to the
|
|
||||||
Corresponding Source in the same way through the same place at no
|
|
||||||
further charge. You need not require recipients to copy the
|
|
||||||
Corresponding Source along with the object code. If the place to
|
|
||||||
copy the object code is a network server, the Corresponding Source
|
|
||||||
may be on a different server (operated by you or a third party)
|
|
||||||
that supports equivalent copying facilities, provided you maintain
|
|
||||||
clear directions next to the object code saying where to find the
|
|
||||||
Corresponding Source. Regardless of what server hosts the
|
|
||||||
Corresponding Source, you remain obligated to ensure that it is
|
|
||||||
available for as long as needed to satisfy these requirements.
|
|
||||||
|
|
||||||
e) Convey the object code using peer-to-peer transmission, provided
|
|
||||||
you inform other peers where the object code and Corresponding
|
|
||||||
Source of the work are being offered to the general public at no
|
|
||||||
charge under subsection 6d.
|
|
||||||
|
|
||||||
A separable portion of the object code, whose source code is excluded
|
|
||||||
from the Corresponding Source as a System Library, need not be
|
|
||||||
included in conveying the object code work.
|
|
||||||
|
|
||||||
A "User Product" is either (1) a "consumer product", which means any
|
|
||||||
tangible personal property which is normally used for personal, family,
|
|
||||||
or household purposes, or (2) anything designed or sold for incorporation
|
|
||||||
into a dwelling. In determining whether a product is a consumer product,
|
|
||||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
|
||||||
product received by a particular user, "normally used" refers to a
|
|
||||||
typical or common use of that class of product, regardless of the status
|
|
||||||
of the particular user or of the way in which the particular user
|
|
||||||
actually uses, or expects or is expected to use, the product. A product
|
|
||||||
is a consumer product regardless of whether the product has substantial
|
|
||||||
commercial, industrial or non-consumer uses, unless such uses represent
|
|
||||||
the only significant mode of use of the product.
|
|
||||||
|
|
||||||
"Installation Information" for a User Product means any methods,
|
|
||||||
procedures, authorization keys, or other information required to install
|
|
||||||
and execute modified versions of a covered work in that User Product from
|
|
||||||
a modified version of its Corresponding Source. The information must
|
|
||||||
suffice to ensure that the continued functioning of the modified object
|
|
||||||
code is in no case prevented or interfered with solely because
|
|
||||||
modification has been made.
|
|
||||||
|
|
||||||
If you convey an object code work under this section in, or with, or
|
|
||||||
specifically for use in, a User Product, and the conveying occurs as
|
|
||||||
part of a transaction in which the right of possession and use of the
|
|
||||||
User Product is transferred to the recipient in perpetuity or for a
|
|
||||||
fixed term (regardless of how the transaction is characterized), the
|
|
||||||
Corresponding Source conveyed under this section must be accompanied
|
|
||||||
by the Installation Information. But this requirement does not apply
|
|
||||||
if neither you nor any third party retains the ability to install
|
|
||||||
modified object code on the User Product (for example, the work has
|
|
||||||
been installed in ROM).
|
|
||||||
|
|
||||||
The requirement to provide Installation Information does not include a
|
|
||||||
requirement to continue to provide support service, warranty, or updates
|
|
||||||
for a work that has been modified or installed by the recipient, or for
|
|
||||||
the User Product in which it has been modified or installed. Access to a
|
|
||||||
network may be denied when the modification itself materially and
|
|
||||||
adversely affects the operation of the network or violates the rules and
|
|
||||||
protocols for communication across the network.
|
|
||||||
|
|
||||||
Corresponding Source conveyed, and Installation Information provided,
|
|
||||||
in accord with this section must be in a format that is publicly
|
|
||||||
documented (and with an implementation available to the public in
|
|
||||||
source code form), and must require no special password or key for
|
|
||||||
unpacking, reading or copying.
|
|
||||||
|
|
||||||
7. Additional Terms.
|
|
||||||
|
|
||||||
"Additional permissions" are terms that supplement the terms of this
|
|
||||||
License by making exceptions from one or more of its conditions.
|
|
||||||
Additional permissions that are applicable to the entire Program shall
|
|
||||||
be treated as though they were included in this License, to the extent
|
|
||||||
that they are valid under applicable law. If additional permissions
|
|
||||||
apply only to part of the Program, that part may be used separately
|
|
||||||
under those permissions, but the entire Program remains governed by
|
|
||||||
this License without regard to the additional permissions.
|
|
||||||
|
|
||||||
When you convey a copy of a covered work, you may at your option
|
|
||||||
remove any additional permissions from that copy, or from any part of
|
|
||||||
it. (Additional permissions may be written to require their own
|
|
||||||
removal in certain cases when you modify the work.) You may place
|
|
||||||
additional permissions on material, added by you to a covered work,
|
|
||||||
for which you have or can give appropriate copyright permission.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, for material you
|
|
||||||
add to a covered work, you may (if authorized by the copyright holders of
|
|
||||||
that material) supplement the terms of this License with terms:
|
|
||||||
|
|
||||||
a) Disclaiming warranty or limiting liability differently from the
|
|
||||||
terms of sections 15 and 16 of this License; or
|
|
||||||
|
|
||||||
b) Requiring preservation of specified reasonable legal notices or
|
|
||||||
author attributions in that material or in the Appropriate Legal
|
|
||||||
Notices displayed by works containing it; or
|
|
||||||
|
|
||||||
c) Prohibiting misrepresentation of the origin of that material, or
|
|
||||||
requiring that modified versions of such material be marked in
|
|
||||||
reasonable ways as different from the original version; or
|
|
||||||
|
|
||||||
d) Limiting the use for publicity purposes of names of licensors or
|
|
||||||
authors of the material; or
|
|
||||||
|
|
||||||
e) Declining to grant rights under trademark law for use of some
|
|
||||||
trade names, trademarks, or service marks; or
|
|
||||||
|
|
||||||
f) Requiring indemnification of licensors and authors of that
|
|
||||||
material by anyone who conveys the material (or modified versions of
|
|
||||||
it) with contractual assumptions of liability to the recipient, for
|
|
||||||
any liability that these contractual assumptions directly impose on
|
|
||||||
those licensors and authors.
|
|
||||||
|
|
||||||
All other non-permissive additional terms are considered "further
|
|
||||||
restrictions" within the meaning of section 10. If the Program as you
|
|
||||||
received it, or any part of it, contains a notice stating that it is
|
|
||||||
governed by this License along with a term that is a further
|
|
||||||
restriction, you may remove that term. If a license document contains
|
|
||||||
a further restriction but permits relicensing or conveying under this
|
|
||||||
License, you may add to a covered work material governed by the terms
|
|
||||||
of that license document, provided that the further restriction does
|
|
||||||
not survive such relicensing or conveying.
|
|
||||||
|
|
||||||
If you add terms to a covered work in accord with this section, you
|
|
||||||
must place, in the relevant source files, a statement of the
|
|
||||||
additional terms that apply to those files, or a notice indicating
|
|
||||||
where to find the applicable terms.
|
|
||||||
|
|
||||||
Additional terms, permissive or non-permissive, may be stated in the
|
|
||||||
form of a separately written license, or stated as exceptions;
|
|
||||||
the above requirements apply either way.
|
|
||||||
|
|
||||||
8. Termination.
|
|
||||||
|
|
||||||
You may not propagate or modify a covered work except as expressly
|
|
||||||
provided under this License. Any attempt otherwise to propagate or
|
|
||||||
modify it is void, and will automatically terminate your rights under
|
|
||||||
this License (including any patent licenses granted under the third
|
|
||||||
paragraph of section 11).
|
|
||||||
|
|
||||||
However, if you cease all violation of this License, then your
|
|
||||||
license from a particular copyright holder is reinstated (a)
|
|
||||||
provisionally, unless and until the copyright holder explicitly and
|
|
||||||
finally terminates your license, and (b) permanently, if the copyright
|
|
||||||
holder fails to notify you of the violation by some reasonable means
|
|
||||||
prior to 60 days after the cessation.
|
|
||||||
|
|
||||||
Moreover, your license from a particular copyright holder is
|
|
||||||
reinstated permanently if the copyright holder notifies you of the
|
|
||||||
violation by some reasonable means, this is the first time you have
|
|
||||||
received notice of violation of this License (for any work) from that
|
|
||||||
copyright holder, and you cure the violation prior to 30 days after
|
|
||||||
your receipt of the notice.
|
|
||||||
|
|
||||||
Termination of your rights under this section does not terminate the
|
|
||||||
licenses of parties who have received copies or rights from you under
|
|
||||||
this License. If your rights have been terminated and not permanently
|
|
||||||
reinstated, you do not qualify to receive new licenses for the same
|
|
||||||
material under section 10.
|
|
||||||
|
|
||||||
9. Acceptance Not Required for Having Copies.
|
|
||||||
|
|
||||||
You are not required to accept this License in order to receive or
|
|
||||||
run a copy of the Program. Ancillary propagation of a covered work
|
|
||||||
occurring solely as a consequence of using peer-to-peer transmission
|
|
||||||
to receive a copy likewise does not require acceptance. However,
|
|
||||||
nothing other than this License grants you permission to propagate or
|
|
||||||
modify any covered work. These actions infringe copyright if you do
|
|
||||||
not accept this License. Therefore, by modifying or propagating a
|
|
||||||
covered work, you indicate your acceptance of this License to do so.
|
|
||||||
|
|
||||||
10. Automatic Licensing of Downstream Recipients.
|
|
||||||
|
|
||||||
Each time you convey a covered work, the recipient automatically
|
|
||||||
receives a license from the original licensors, to run, modify and
|
|
||||||
propagate that work, subject to this License. You are not responsible
|
|
||||||
for enforcing compliance by third parties with this License.
|
|
||||||
|
|
||||||
An "entity transaction" is a transaction transferring control of an
|
|
||||||
organization, or substantially all assets of one, or subdividing an
|
|
||||||
organization, or merging organizations. If propagation of a covered
|
|
||||||
work results from an entity transaction, each party to that
|
|
||||||
transaction who receives a copy of the work also receives whatever
|
|
||||||
licenses to the work the party's predecessor in interest had or could
|
|
||||||
give under the previous paragraph, plus a right to possession of the
|
|
||||||
Corresponding Source of the work from the predecessor in interest, if
|
|
||||||
the predecessor has it or can get it with reasonable efforts.
|
|
||||||
|
|
||||||
You may not impose any further restrictions on the exercise of the
|
|
||||||
rights granted or affirmed under this License. For example, you may
|
|
||||||
not impose a license fee, royalty, or other charge for exercise of
|
|
||||||
rights granted under this License, and you may not initiate litigation
|
|
||||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
|
||||||
any patent claim is infringed by making, using, selling, offering for
|
|
||||||
sale, or importing the Program or any portion of it.
|
|
||||||
|
|
||||||
11. Patents.
|
|
||||||
|
|
||||||
A "contributor" is a copyright holder who authorizes use under this
|
|
||||||
License of the Program or a work on which the Program is based. The
|
|
||||||
work thus licensed is called the contributor's "contributor version".
|
|
||||||
|
|
||||||
A contributor's "essential patent claims" are all patent claims
|
|
||||||
owned or controlled by the contributor, whether already acquired or
|
|
||||||
hereafter acquired, that would be infringed by some manner, permitted
|
|
||||||
by this License, of making, using, or selling its contributor version,
|
|
||||||
but do not include claims that would be infringed only as a
|
|
||||||
consequence of further modification of the contributor version. For
|
|
||||||
purposes of this definition, "control" includes the right to grant
|
|
||||||
patent sublicenses in a manner consistent with the requirements of
|
|
||||||
this License.
|
|
||||||
|
|
||||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
|
||||||
patent license under the contributor's essential patent claims, to
|
|
||||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
|
||||||
propagate the contents of its contributor version.
|
|
||||||
|
|
||||||
In the following three paragraphs, a "patent license" is any express
|
|
||||||
agreement or commitment, however denominated, not to enforce a patent
|
|
||||||
(such as an express permission to practice a patent or covenant not to
|
|
||||||
sue for patent infringement). To "grant" such a patent license to a
|
|
||||||
party means to make such an agreement or commitment not to enforce a
|
|
||||||
patent against the party.
|
|
||||||
|
|
||||||
If you convey a covered work, knowingly relying on a patent license,
|
|
||||||
and the Corresponding Source of the work is not available for anyone
|
|
||||||
to copy, free of charge and under the terms of this License, through a
|
|
||||||
publicly available network server or other readily accessible means,
|
|
||||||
then you must either (1) cause the Corresponding Source to be so
|
|
||||||
available, or (2) arrange to deprive yourself of the benefit of the
|
|
||||||
patent license for this particular work, or (3) arrange, in a manner
|
|
||||||
consistent with the requirements of this License, to extend the patent
|
|
||||||
license to downstream recipients. "Knowingly relying" means you have
|
|
||||||
actual knowledge that, but for the patent license, your conveying the
|
|
||||||
covered work in a country, or your recipient's use of the covered work
|
|
||||||
in a country, would infringe one or more identifiable patents in that
|
|
||||||
country that you have reason to believe are valid.
|
|
||||||
|
|
||||||
If, pursuant to or in connection with a single transaction or
|
|
||||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
|
||||||
covered work, and grant a patent license to some of the parties
|
|
||||||
receiving the covered work authorizing them to use, propagate, modify
|
|
||||||
or convey a specific copy of the covered work, then the patent license
|
|
||||||
you grant is automatically extended to all recipients of the covered
|
|
||||||
work and works based on it.
|
|
||||||
|
|
||||||
A patent license is "discriminatory" if it does not include within
|
|
||||||
the scope of its coverage, prohibits the exercise of, or is
|
|
||||||
conditioned on the non-exercise of one or more of the rights that are
|
|
||||||
specifically granted under this License. You may not convey a covered
|
|
||||||
work if you are a party to an arrangement with a third party that is
|
|
||||||
in the business of distributing software, under which you make payment
|
|
||||||
to the third party based on the extent of your activity of conveying
|
|
||||||
the work, and under which the third party grants, to any of the
|
|
||||||
parties who would receive the covered work from you, a discriminatory
|
|
||||||
patent license (a) in connection with copies of the covered work
|
|
||||||
conveyed by you (or copies made from those copies), or (b) primarily
|
|
||||||
for and in connection with specific products or compilations that
|
|
||||||
contain the covered work, unless you entered into that arrangement,
|
|
||||||
or that patent license was granted, prior to 28 March 2007.
|
|
||||||
|
|
||||||
Nothing in this License shall be construed as excluding or limiting
|
|
||||||
any implied license or other defenses to infringement that may
|
|
||||||
otherwise be available to you under applicable patent law.
|
|
||||||
|
|
||||||
12. No Surrender of Others' Freedom.
|
|
||||||
|
|
||||||
If conditions are imposed on you (whether by court order, agreement or
|
|
||||||
otherwise) that contradict the conditions of this License, they do not
|
|
||||||
excuse you from the conditions of this License. If you cannot convey a
|
|
||||||
covered work so as to satisfy simultaneously your obligations under this
|
|
||||||
License and any other pertinent obligations, then as a consequence you may
|
|
||||||
not convey it at all. For example, if you agree to terms that obligate you
|
|
||||||
to collect a royalty for further conveying from those to whom you convey
|
|
||||||
the Program, the only way you could satisfy both those terms and this
|
|
||||||
License would be to refrain entirely from conveying the Program.
|
|
||||||
|
|
||||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, if you modify the
|
|
||||||
Program, your modified version must prominently offer all users
|
|
||||||
interacting with it remotely through a computer network (if your version
|
|
||||||
supports such interaction) an opportunity to receive the Corresponding
|
|
||||||
Source of your version by providing access to the Corresponding Source
|
|
||||||
from a network server at no charge, through some standard or customary
|
|
||||||
means of facilitating copying of software. This Corresponding Source
|
|
||||||
shall include the Corresponding Source for any work covered by version 3
|
|
||||||
of the GNU General Public License that is incorporated pursuant to the
|
|
||||||
following paragraph.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have
|
|
||||||
permission to link or combine any covered work with a work licensed
|
|
||||||
under version 3 of the GNU General Public License into a single
|
|
||||||
combined work, and to convey the resulting work. The terms of this
|
|
||||||
License will continue to apply to the part which is the covered work,
|
|
||||||
but the work with which it is combined will remain governed by version
|
|
||||||
3 of the GNU General Public License.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
|
||||||
the GNU Affero General Public License from time to time. Such new versions
|
|
||||||
will be similar in spirit to the present version, but may differ in detail to
|
|
||||||
address new problems or concerns.
|
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
|
||||||
Program specifies that a certain numbered version of the GNU Affero General
|
|
||||||
Public License "or any later version" applies to it, you have the
|
|
||||||
option of following the terms and conditions either of that numbered
|
|
||||||
version or of any later version published by the Free Software
|
|
||||||
Foundation. If the Program does not specify a version number of the
|
|
||||||
GNU Affero General Public License, you may choose any version ever published
|
|
||||||
by the Free Software Foundation.
|
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
|
||||||
versions of the GNU Affero General Public License can be used, that proxy's
|
|
||||||
public statement of acceptance of a version permanently authorizes you
|
|
||||||
to choose that version for the Program.
|
|
||||||
|
|
||||||
Later license versions may give you additional or different
|
|
||||||
permissions. However, no additional obligations are imposed on any
|
|
||||||
author or copyright holder as a result of your choosing to follow a
|
|
||||||
later version.
|
|
||||||
|
|
||||||
15. Disclaimer of Warranty.
|
|
||||||
|
|
||||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
|
||||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
|
||||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
|
||||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
|
||||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
|
||||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
|
||||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
|
||||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
|
||||||
|
|
||||||
16. Limitation of Liability.
|
|
||||||
|
|
||||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
|
||||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
|
||||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
|
||||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
|
||||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
|
||||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
|
||||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
|
||||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
|
||||||
SUCH DAMAGES.
|
|
||||||
|
|
||||||
17. Interpretation of Sections 15 and 16.
|
|
||||||
|
|
||||||
If the disclaimer of warranty and limitation of liability provided
|
|
||||||
above cannot be given local legal effect according to their terms,
|
|
||||||
reviewing courts shall apply local law that most closely approximates
|
|
||||||
an absolute waiver of all civil liability in connection with the
|
|
||||||
Program, unless a warranty or assumption of liability accompanies a
|
|
||||||
copy of the Program in return for a fee.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
How to Apply These Terms to Your New Programs
|
|
||||||
|
|
||||||
If you develop a new program, and you want it to be of the greatest
|
|
||||||
possible use to the public, the best way to achieve this is to make it
|
|
||||||
free software which everyone can redistribute and change under these terms.
|
|
||||||
|
|
||||||
To do so, attach the following notices to the program. It is safest
|
|
||||||
to attach them to the start of each source file to most effectively
|
|
||||||
state the exclusion of warranty; and each file should have at least
|
|
||||||
the "copyright" line and a pointer to where the full notice is found.
|
|
||||||
|
|
||||||
<one line to give the program's name and a brief idea of what it does.>
|
|
||||||
Copyright (C) <year> <name of author>
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
|
||||||
|
|
||||||
If your software can interact with users remotely through a computer
|
|
||||||
network, you should also make sure that it provides a way for users to
|
|
||||||
get its source. For example, if your program is a web application, its
|
|
||||||
interface could display a "Source" link that leads users to an archive
|
|
||||||
of the code. There are many ways you could offer source, and different
|
|
||||||
solutions will be better for different programs; see section 13 for the
|
|
||||||
specific requirements.
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
|
||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
|
||||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
|
||||||
<https://www.gnu.org/licenses/>.
|
|
||||||
144
PRIVACY.md
144
PRIVACY.md
|
|
@ -1,144 +0,0 @@
|
||||||
# Sojorn — Privacy & Data Sovereignty
|
|
||||||
|
|
||||||
**Effective Date:** February 12, 2026
|
|
||||||
**Last Updated:** February 17, 2026
|
|
||||||
**Operator:** MPLS LLC
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Our Philosophy: Privacy as a Sanctuary
|
|
||||||
|
|
||||||
Profiting from surveillance is strictly against our principles. We reject the "attention economy" model entirely.
|
|
||||||
|
|
||||||
Most social platforms treat your data as their product. They harvest your posts, your photos, your location, your relationships, and your attention — then sell access to the highest bidder. We built Sojorn to prove that a social network can exist without any of that.
|
|
||||||
|
|
||||||
**Sojorn is a walled garden where your data is not a commodity.** We are groundskeepers of this space — not owners of what grows in it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Data Sovereignty
|
|
||||||
|
|
||||||
We do not sell your data. We do not license your data. We do not provide your data to third-party analytics, advertising, or data brokerage firms. Your content is not indexed on public search engines. Sojorn is a private community designed to protect your posts and identity from the extractivist economy.
|
|
||||||
|
|
||||||
## 2. What We Collect
|
|
||||||
|
|
||||||
We collect only what is technically necessary to operate the Service:
|
|
||||||
|
|
||||||
| Data | Purpose | Retention |
|
|
||||||
|---|---|---|
|
|
||||||
| **Email address** | Authentication, critical account notifications | Until account deletion |
|
|
||||||
| **Birth month & year** | Age verification (16+ requirement) | Until account deletion |
|
|
||||||
| **Display name & handle** | Profile identity within the network | Until account deletion |
|
|
||||||
| **Content you create** | Posts, comments, images, video — displayed to your chosen audience | Until you delete it |
|
|
||||||
| **Approximate location** (Beacons only) | Community safety incident reporting | Ephemeral — not stored permanently |
|
|
||||||
| **Device push tokens** | Delivering notifications you have opted into | Until account deletion or token refresh |
|
|
||||||
|
|
||||||
We do **not** collect:
|
|
||||||
|
|
||||||
- Precise GPS location outside of Beacons
|
|
||||||
- Contact lists or phone books
|
|
||||||
- Browsing history outside of Sojorn
|
|
||||||
- Biometric data
|
|
||||||
- Financial information
|
|
||||||
|
|
||||||
## 3. Third-Party Services
|
|
||||||
|
|
||||||
| Service | Purpose | Data Shared |
|
|
||||||
|---|---|---|
|
|
||||||
| **Firebase** | Authentication, push notifications | Email, device token |
|
|
||||||
| **Cloudflare R2** | Media file storage (images, video) | Uploaded media files |
|
|
||||||
| **SendPulse** | Newsletter delivery (opt-in only) | Email address |
|
|
||||||
| **OpenAI / Google Vision** | Content moderation (hate speech, violence detection) | Text snippets and image URLs of public posts only |
|
|
||||||
|
|
||||||
We do **not** use third-party tracking pixels, cross-site cookies, behavioral analytics, or advertising SDKs.
|
|
||||||
|
|
||||||
### AI Moderation Disclosure
|
|
||||||
|
|
||||||
Public posts may be analyzed by AI moderation systems to detect policy violations (hate speech, violence, spam, NSFW content). This analysis:
|
|
||||||
|
|
||||||
- Is performed only on content you post publicly or within groups.
|
|
||||||
- Does **not** apply to end-to-end encrypted messages or capsule content.
|
|
||||||
- Does **not** train AI models on your content — we use pre-trained safety classifiers only.
|
|
||||||
- Is subject to human review before permanent moderation action.
|
|
||||||
- Produces an audit trail visible to administrators for accountability.
|
|
||||||
|
|
||||||
## 4. Zero-Knowledge Encryption
|
|
||||||
|
|
||||||
Private messages and encrypted capsule content are protected by end-to-end encryption (E2EE) using keys generated on your device. Your encryption keys are wrapped with a passphrase only you know and stored as an opaque encrypted blob on our servers. **We cannot decrypt your private content.** We cannot comply with requests to produce content we cannot read.
|
|
||||||
|
|
||||||
## 5. Your Right to Vanish
|
|
||||||
|
|
||||||
You have the absolute right to delete your account and all associated data at any time.
|
|
||||||
|
|
||||||
When you delete content or your account, we perform **hard deletes**:
|
|
||||||
|
|
||||||
- Database records are permanently removed (not soft-deleted).
|
|
||||||
- Media files (images, video) are permanently removed from storage buckets.
|
|
||||||
- Encryption key backups are permanently removed.
|
|
||||||
- We do not retain shadow copies, hidden archives, or behavioral profiles.
|
|
||||||
|
|
||||||
When you leave, you leave.
|
|
||||||
|
|
||||||
## 6. Anti-Extraction Commitment
|
|
||||||
|
|
||||||
MPLS LLC will never:
|
|
||||||
|
|
||||||
- Use your content to train artificial intelligence or machine learning models.
|
|
||||||
- Sell, license, or share your content with data brokers or advertisers.
|
|
||||||
- Build advertising or behavioral profiles from your activity.
|
|
||||||
- Provide "data partnerships" or "audience insights" products derived from your content.
|
|
||||||
|
|
||||||
## 7. Right to Livelihood
|
|
||||||
|
|
||||||
If MPLS LLC ever wishes to feature your content in promotional materials outside of the Sojorn app interface, we must contact you directly, offer financial compensation, and receive your explicit written consent. See Section 4.5 of our [Terms of Service](https://sojorn.net/terms) for full details.
|
|
||||||
|
|
||||||
## 8. Anti-Scraping
|
|
||||||
|
|
||||||
We actively defend against unauthorized commercial harvesting of user content through rate limiting, authentication requirements, and automated abuse detection. Unauthorized scraping of Sojorn content is a violation of these Terms and may be pursued under the Computer Fraud and Abuse Act (CFAA).
|
|
||||||
|
|
||||||
## 9. Law Enforcement
|
|
||||||
|
|
||||||
We will comply with valid legal process (court orders, subpoenas) as required by law. However:
|
|
||||||
|
|
||||||
- We will notify affected users unless legally prohibited from doing so.
|
|
||||||
- We cannot produce end-to-end encrypted content (we do not have the keys).
|
|
||||||
- We will challenge overbroad or legally deficient requests.
|
|
||||||
- We will publish a transparency report annually documenting any government data requests received.
|
|
||||||
|
|
||||||
## 10. Children's Privacy
|
|
||||||
|
|
||||||
Sojorn is not intended for users under 16. We do not knowingly collect data from children. If we discover that a user is under 16, we will delete their account and all associated data.
|
|
||||||
|
|
||||||
## 11. International Users
|
|
||||||
|
|
||||||
Sojorn is operated by MPLS LLC from the United States. If you are accessing the Service from the European Union, your data is processed in the United States. We apply the same privacy protections to all users regardless of jurisdiction.
|
|
||||||
|
|
||||||
## 12. Changes to This Policy
|
|
||||||
|
|
||||||
We will notify registered users via email and in-app notification of any material changes to this Privacy Policy at least 30 days before they take effect.
|
|
||||||
|
|
||||||
## 13. Contact
|
|
||||||
|
|
||||||
For privacy concerns: [privacy@sojorn.net](mailto:privacy@sojorn.net)
|
|
||||||
For legal inquiries: [legal@mp.ls](mailto:legal@mp.ls)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Why We Chose This Model
|
|
||||||
|
|
||||||
### Right Livelihood for Creators
|
|
||||||
|
|
||||||
Our source code is published under the [GNU Affero General Public License v3.0](./LICENSE). We share our code so that users, security researchers, and the public can verify that we honor every commitment in this document. We chose this license because it ensures that all modifications — including those running on network servers — remain open and available to the community.
|
|
||||||
|
|
||||||
We call this **Right Livelihood for Creators** — we share our work for your safety, and we protect user freedom so we can remain independent and never need to monetize your attention or your data.
|
|
||||||
|
|
||||||
### Privacy as a Sanctuary
|
|
||||||
|
|
||||||
Every technical decision we make is measured against a simple question: *Does this protect or erode the sanctuary?*
|
|
||||||
|
|
||||||
- We chose E2EE for private messages — because a sanctuary has walls.
|
|
||||||
- We chose hard deletes — because a sanctuary does not hoard what you discard.
|
|
||||||
- We chose AI moderation with human review — because a sanctuary has guardians, not surveillance cameras.
|
|
||||||
- We chose no advertising SDK — because a sanctuary is not a billboard.
|
|
||||||
|
|
||||||
**MPLS LLC — Groundskeepers, not owners.**
|
|
||||||
112
TERMS.md
112
TERMS.md
|
|
@ -1,112 +0,0 @@
|
||||||
# Sojorn — Terms of Service
|
|
||||||
|
|
||||||
**Effective Date:** February 12, 2026
|
|
||||||
**Last Updated:** February 17, 2026
|
|
||||||
**Operator:** MPLS LLC
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. The Agreement
|
|
||||||
|
|
||||||
By accessing Sojorn ("the Service"), you acknowledge that you are entering a space dedicated to respect, safety, and progressive action. These Terms of Service ("Terms") constitute a binding agreement between you and MPLS LLC ("we," "us," "our"). We prioritize the safety of our community above all else.
|
|
||||||
|
|
||||||
## 2. Zero Tolerance Policy
|
|
||||||
|
|
||||||
We do not tolerate intolerance. Hate speech, racism, sexism, homophobia, transphobia, ableism, and fascist ideologies are strictly prohibited. Violations will result in immediate and permanent account suspension.
|
|
||||||
|
|
||||||
## 3. No Misinformation
|
|
||||||
|
|
||||||
We reject the spread of verifiable falsehoods, conspiracy theories, and coordinated disinformation campaigns. Posting content designed to deceive or manipulate will result in account termination.
|
|
||||||
|
|
||||||
## 4. Content Ownership and Sanctuary
|
|
||||||
|
|
||||||
This section replaces the broad content licenses found in conventional social media Terms of Service. We believe your work belongs to you — always.
|
|
||||||
|
|
||||||
### 4.1 Ownership
|
|
||||||
|
|
||||||
**You retain 100% copyright and all intellectual property rights to every piece of content you create on Sojorn.** We claim no ownership over your words, images, audio, video, or any other creative work.
|
|
||||||
|
|
||||||
### 4.2 Limited Technical License
|
|
||||||
|
|
||||||
By posting content, you grant MPLS LLC a **non-exclusive, royalty-free, worldwide license solely for the technical purpose of hosting, displaying, and transmitting your content** to the audience you designate within Sojorn. This license exists only so that our servers can store and deliver your content as the software requires.
|
|
||||||
|
|
||||||
### 4.3 Immediate Revocation
|
|
||||||
|
|
||||||
This technical license **expires immediately and irrevocably upon deletion** of the content by you. When you delete a post, comment, image, or any other content, we will remove it from our active systems and storage buckets. We do not retain shadow copies, hidden archives, or "soft-deleted" records of your content.
|
|
||||||
|
|
||||||
### 4.4 Anti-Extraction Covenant
|
|
||||||
|
|
||||||
MPLS LLC **will never**:
|
|
||||||
|
|
||||||
- Use your content to train artificial intelligence or machine learning models.
|
|
||||||
- Sell, license, or provide your content to third-party data brokers, advertisers, or analytics firms.
|
|
||||||
- Mine your content for advertising profiles or behavioral targeting.
|
|
||||||
- Index your content on public search engines without your explicit opt-in.
|
|
||||||
|
|
||||||
### 4.5 Right to Livelihood
|
|
||||||
|
|
||||||
If MPLS LLC ever wishes to use your content for promotional purposes outside the Sojorn application interface — including but not limited to marketing materials, press releases, social media promotion, or investor presentations — we must:
|
|
||||||
|
|
||||||
1. **Contact you directly** with a clear written description of the intended use.
|
|
||||||
2. **Offer financial compensation** for that use.
|
|
||||||
3. **Receive your explicit, written opt-in consent** before any such use.
|
|
||||||
|
|
||||||
No blanket consent is granted by agreeing to these Terms. Each promotional use requires a separate agreement.
|
|
||||||
|
|
||||||
## 5. Your Right to Vanish
|
|
||||||
|
|
||||||
You have the absolute right to delete your account and all associated data at any time. When you leave, you leave. We perform hard deletes — your profile, posts, comments, media files, and metadata are permanently removed from our systems.
|
|
||||||
|
|
||||||
## 6. End-to-End Encryption
|
|
||||||
|
|
||||||
Private messages and encrypted capsule content are protected by end-to-end encryption (E2EE) using keys generated on your device. MPLS LLC has no ability to decrypt, read, or access this content. We cannot comply with requests to produce content we cannot read.
|
|
||||||
|
|
||||||
## 7. AI Moderation Transparency
|
|
||||||
|
|
||||||
We use artificial intelligence to assist with content moderation (detecting hate speech, violence, spam, and other policy violations). This AI moderation:
|
|
||||||
|
|
||||||
- Operates only on content you post publicly or within groups.
|
|
||||||
- Does not analyze private encrypted messages (we cannot).
|
|
||||||
- Is subject to human review — AI flags are reviewed by human moderators before permanent action is taken.
|
|
||||||
- Provides a full audit trail visible to administrators for accountability.
|
|
||||||
|
|
||||||
You may appeal any AI moderation decision through our in-app appeal process.
|
|
||||||
|
|
||||||
## 8. Community Safety Beacons
|
|
||||||
|
|
||||||
Sojorn includes a community safety feature ("Beacons") that allows users to report real-world safety incidents with location data. By using this feature, you consent to sharing approximate location data with other Sojorn users in your vicinity. Location data is not stored permanently and is not sold to third parties.
|
|
||||||
|
|
||||||
## 9. Age Requirement
|
|
||||||
|
|
||||||
You must be at least 16 years of age to use Sojorn. We collect birth month and year during registration solely to enforce this requirement.
|
|
||||||
|
|
||||||
## 10. Liability
|
|
||||||
|
|
||||||
MPLS LLC provides this Service "as is." We are not liable for interactions that occur between users, though we commit to active moderation to maintain community safety. We are not liable for the accuracy of community safety Beacons posted by users.
|
|
||||||
|
|
||||||
## 11. Governing Law
|
|
||||||
|
|
||||||
These Terms are governed by the laws of the State of Minnesota, United States.
|
|
||||||
|
|
||||||
## 12. Changes to These Terms
|
|
||||||
|
|
||||||
We will notify registered users via email and in-app notification of any material changes to these Terms at least 30 days before they take effect.
|
|
||||||
|
|
||||||
## 13. Open Source Licensing
|
|
||||||
|
|
||||||
Sojorn's source code is published under the [GNU Affero General Public License v3.0](https://www.gnu.org/licenses/agpl-3.0.html) (AGPL-3.0). This means:
|
|
||||||
|
|
||||||
- The complete source code for Sojorn is publicly available.
|
|
||||||
- Anyone may inspect, modify, and redistribute the code under the terms of the AGPL-3.0.
|
|
||||||
- Any modified version of Sojorn that is made available over a network must also make its complete source code available under the same license.
|
|
||||||
- This license ensures that all improvements to Sojorn — including those running on third-party servers — remain open and available to the community.
|
|
||||||
|
|
||||||
Our source code repository is hosted at [gitlab.com/patrickbritton3/sojorn](https://gitlab.com/patrickbritton3/sojorn).
|
|
||||||
|
|
||||||
## 14. Contact
|
|
||||||
|
|
||||||
For questions about these Terms: [legal@mp.ls](mailto:legal@mp.ls)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*MPLS LLC — Groundskeepers, not owners.*
|
|
||||||
174
_legacy/supabase/BEACON_SYSTEM_EXPLAINED.md
Normal file
174
_legacy/supabase/BEACON_SYSTEM_EXPLAINED.md
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
# Beacon System Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Sojorn has **two separate posting systems** that share the same database but create slightly different content:
|
||||||
|
|
||||||
|
1. **Regular Posts** (compose_screen.dart) - Standard social media posts
|
||||||
|
2. **Beacon Posts** (create-beacon_sheet.dart) - GPS-tagged safety alerts
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### Database Structure
|
||||||
|
|
||||||
|
Both systems create records in the `posts` table, but with different flags:
|
||||||
|
|
||||||
|
| Field | Regular Post | Beacon Post |
|
||||||
|
|-------|-------------|-------------|
|
||||||
|
| `is_beacon` | `FALSE` | `TRUE` |
|
||||||
|
| `beacon_type` | `NULL` | `'police'`, `'checkpoint'`, `'taskForce'`, `'hazard'`, `'safety'`, or `'community'` |
|
||||||
|
| `location` | `NULL` | GPS coordinates (PostGIS POINT) |
|
||||||
|
| `confidence_score` | `NULL` | 0.5 - 1.0 (starts at 50-80% based on user trust) |
|
||||||
|
| `is_active_beacon` | `NULL` | `TRUE` (becomes `FALSE` when pruned) |
|
||||||
|
| `allow_chain` | User choice | Always `FALSE` |
|
||||||
|
|
||||||
|
### User Opt-In System
|
||||||
|
|
||||||
|
**Critical Feature**: Beacon posts are **OPT-IN ONLY** for feeds.
|
||||||
|
|
||||||
|
#### `profiles.beacon_enabled` Column
|
||||||
|
- **Default**: `FALSE` (opted out)
|
||||||
|
- **When FALSE**: User NEVER sees beacon posts in their Following or Sojorn feeds
|
||||||
|
- **When TRUE**: User sees beacon posts mixed in with regular posts
|
||||||
|
- **Beacon Map**: ALWAYS visible regardless of this setting
|
||||||
|
|
||||||
|
#### Why Opt-In?
|
||||||
|
|
||||||
|
Some users don't want safety alerts mixed into their social feed. The opt-in system allows:
|
||||||
|
- **Casual users**: Just social content
|
||||||
|
- **Community safety advocates**: Social content + beacons
|
||||||
|
- **Everyone**: Can still view the Beacon Network map anytime
|
||||||
|
|
||||||
|
### Feed Filtering Logic
|
||||||
|
|
||||||
|
#### feed-personal (Following Feed)
|
||||||
|
```typescript
|
||||||
|
// Check user's beacon preference
|
||||||
|
const { data: profile } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("beacon_enabled")
|
||||||
|
.eq("id", user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
const beaconEnabled = profile?.beacon_enabled || false;
|
||||||
|
|
||||||
|
// Build query
|
||||||
|
let postsQuery = supabase.from("posts").select(...);
|
||||||
|
|
||||||
|
// Filter out beacons if user has NOT opted in
|
||||||
|
if (!beaconEnabled) {
|
||||||
|
postsQuery = postsQuery.eq("is_beacon", false);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### feed-sojorn (Algorithmic Feed)
|
||||||
|
Same logic - beacons are filtered unless `beacon_enabled = TRUE`.
|
||||||
|
|
||||||
|
### Beacon Creation Flow
|
||||||
|
|
||||||
|
1. User opens Beacon Network tab
|
||||||
|
2. Taps map to drop beacon
|
||||||
|
3. Fills out CreateBeaconSheet:
|
||||||
|
- Type (police, checkpoint, etc.)
|
||||||
|
- Title
|
||||||
|
- Description
|
||||||
|
- Optional photo
|
||||||
|
4. Submits → Edge function `create-beacon`
|
||||||
|
5. **Creates a POST in the `posts` table** with:
|
||||||
|
- `is_beacon = TRUE`
|
||||||
|
- `beacon_type = <selected_type>`
|
||||||
|
- `location = GPS point`
|
||||||
|
- `category_id = "Beacon Alerts"` category
|
||||||
|
- `confidence_score` based on user's trust score
|
||||||
|
- `allow_chain = FALSE`
|
||||||
|
|
||||||
|
### Regular Post Creation Flow
|
||||||
|
|
||||||
|
1. User taps "New Post" button
|
||||||
|
2. Fills out ComposeScreen:
|
||||||
|
- Community selection
|
||||||
|
- Body text
|
||||||
|
- Optional photo
|
||||||
|
- Toggle for chain responses
|
||||||
|
3. Submits → Edge function `publish-post`
|
||||||
|
4. **Creates a POST in the `posts` table** with:
|
||||||
|
- `is_beacon = FALSE`
|
||||||
|
- No GPS data
|
||||||
|
- User-selected category
|
||||||
|
- User's chain preference
|
||||||
|
|
||||||
|
## Key Differences
|
||||||
|
|
||||||
|
| Feature | Regular Post | Beacon Post |
|
||||||
|
|---------|-------------|-------------|
|
||||||
|
| **Purpose** | Social sharing | Safety alerts |
|
||||||
|
| **GPS Data** | No | Required |
|
||||||
|
| **Visible On** | Feeds (if user follows author) | Beacon map + feeds (if user opted in) |
|
||||||
|
| **Category** | User selects | Always "Beacon Alerts" |
|
||||||
|
| **Chaining** | User choice | Disabled |
|
||||||
|
| **Confidence Score** | No | Yes (trust-based) |
|
||||||
|
| **Voting** | No | Yes (vouch/report) |
|
||||||
|
| **Auto-Pruning** | No | Yes (low confidence + old = disabled) |
|
||||||
|
|
||||||
|
## User Experience Scenarios
|
||||||
|
|
||||||
|
### Scenario 1: User With Beacons Disabled (Default)
|
||||||
|
```
|
||||||
|
Following Feed: ✓ Regular posts from people they follow
|
||||||
|
Sojorn Feed: ✓ Algorithmic regular posts
|
||||||
|
Beacon Map: ✓ All active beacons in area
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 2: User With Beacons Enabled
|
||||||
|
```
|
||||||
|
Following Feed: ✓ Regular posts + beacons from people they follow
|
||||||
|
Sojorn Feed: ✓ Algorithmic regular posts + beacons
|
||||||
|
Beacon Map: ✓ All active beacons in area
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 3: User Creates Beacon
|
||||||
|
1. Beacon appears on map IMMEDIATELY for ALL users
|
||||||
|
2. Beacon appears in creator's feed (if they have beacons enabled)
|
||||||
|
3. Beacon appears in OTHER users' feeds (if they follow creator AND have beacons enabled)
|
||||||
|
|
||||||
|
## Migration Required
|
||||||
|
|
||||||
|
To enable this system, run:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Add beacon_enabled column to profiles
|
||||||
|
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS beacon_enabled BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- Add index for fast filtering
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profiles_beacon_enabled ON profiles(beacon_enabled) WHERE beacon_enabled = TRUE;
|
||||||
|
```
|
||||||
|
|
||||||
|
Or apply the migration file:
|
||||||
|
```bash
|
||||||
|
# Via Supabase Dashboard SQL Editor
|
||||||
|
# Paste contents of: supabase/migrations/add_beacon_opt_in.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## Edge Functions Updated
|
||||||
|
|
||||||
|
1. ✅ **feed-personal** - Now filters beacons based on user preference
|
||||||
|
2. ✅ **feed-sojorn** - Now filters beacons based on user preference
|
||||||
|
3. ✅ **create-beacon** - Creates beacon posts correctly
|
||||||
|
4. ✅ **publish-post** - Creates regular posts correctly
|
||||||
|
|
||||||
|
## Frontend Components
|
||||||
|
|
||||||
|
- ✅ **ComposeScreen** - Regular post composer
|
||||||
|
- ✅ **CreateBeaconSheet** - Beacon post composer
|
||||||
|
- 🔲 **Settings Screen** - TODO: Add toggle for `beacon_enabled` preference
|
||||||
|
- ✅ **BeaconScreen** - Shows beacons on map (always visible)
|
||||||
|
- ✅ **FeedPersonalScreen** - Filtered feed
|
||||||
|
- ✅ **FeedSojornScreen** - Filtered feed
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Apply database migration (`add_beacon_opt_in.sql`)
|
||||||
|
2. Deploy updated edge functions
|
||||||
|
3. Add UI toggle in user settings for beacon opt-in
|
||||||
|
4. Test both posting flows
|
||||||
|
5. Verify feed filtering works correctly
|
||||||
61
_legacy/supabase/CREATE_SEARCH_VIEW.md
Normal file
61
_legacy/supabase/CREATE_SEARCH_VIEW.md
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
# Create Search Tags View
|
||||||
|
|
||||||
|
The search function requires a database view called `view_searchable_tags` for efficient tag searching.
|
||||||
|
|
||||||
|
## Why This Is Needed
|
||||||
|
|
||||||
|
Without this view, the search function would need to download ALL posts from the database just to count tags, which will:
|
||||||
|
- Timeout with 1000+ posts
|
||||||
|
- Crash the Edge Function
|
||||||
|
- Cause poor performance
|
||||||
|
|
||||||
|
The view pre-aggregates tag counts at the database level, making searches instant.
|
||||||
|
|
||||||
|
## How to Create the View
|
||||||
|
|
||||||
|
### Option 1: Via Supabase Dashboard (Recommended)
|
||||||
|
|
||||||
|
1. Go to your Supabase project's SQL Editor:
|
||||||
|
https://supabase.com/dashboard/project/zwkihedetedlatyvplyz/sql
|
||||||
|
|
||||||
|
2. Paste and run this SQL:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE OR REPLACE VIEW view_searchable_tags AS
|
||||||
|
SELECT
|
||||||
|
unnest(tags) as tag,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM posts
|
||||||
|
WHERE
|
||||||
|
deleted_at IS NULL
|
||||||
|
AND tags IS NOT NULL
|
||||||
|
AND array_length(tags, 1) > 0
|
||||||
|
GROUP BY unnest(tags)
|
||||||
|
ORDER BY count DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Click "RUN" to execute
|
||||||
|
|
||||||
|
### Option 2: Via PowerShell (if you have psql installed)
|
||||||
|
|
||||||
|
Run this from the project root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-Content supabase\migrations\create_searchable_tags_view.sql | psql $DATABASE_URL
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `$DATABASE_URL` with your Supabase database connection string.
|
||||||
|
|
||||||
|
## Verifying It Works
|
||||||
|
|
||||||
|
After creating the view, test it with:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT * FROM view_searchable_tags LIMIT 10;
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see a list of tags with their counts.
|
||||||
|
|
||||||
|
## What Happens If You Don't Create It?
|
||||||
|
|
||||||
|
The search function will return an error when searching for tags. Users and posts will still work fine, but tag search will fail until this view is created.
|
||||||
70
_legacy/supabase/MIGRATION_INSTRUCTIONS.md
Normal file
70
_legacy/supabase/MIGRATION_INSTRUCTIONS.md
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
# Database Migration: Enhanced Search Function
|
||||||
|
|
||||||
|
This migration updates the `search_sojorn()` function to enable full-text search across posts, users, and hashtags.
|
||||||
|
|
||||||
|
## What Changed
|
||||||
|
|
||||||
|
The search function now searches:
|
||||||
|
- **Users**: by handle AND display name (previously only handle)
|
||||||
|
- **Tags**: hashtags from the posts.tags array (unchanged)
|
||||||
|
- **Posts**: NEW - searches post body content for any word, matching hashtags
|
||||||
|
|
||||||
|
## Option 1: Apply via Supabase Dashboard (Recommended)
|
||||||
|
|
||||||
|
1. Go to your Supabase Dashboard: https://app.supabase.com/project/zwkihedetedlatyvplyz
|
||||||
|
|
||||||
|
2. Navigate to **SQL Editor** in the left sidebar
|
||||||
|
|
||||||
|
3. Click **"New Query"**
|
||||||
|
|
||||||
|
4. Copy and paste the SQL from `supabase/migrations/update_search_function.sql`
|
||||||
|
|
||||||
|
5. Click **"Run"** to execute the migration
|
||||||
|
|
||||||
|
6. Verify success - you should see "Success. No rows returned"
|
||||||
|
|
||||||
|
## Option 2: Apply via Supabase CLI
|
||||||
|
|
||||||
|
If you have Supabase CLI configured with your project:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Link to your project (if not already linked)
|
||||||
|
supabase link --project-ref zwkihedetedlatyvplyz
|
||||||
|
|
||||||
|
# Push the migration
|
||||||
|
supabase db push --include-all
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After applying the migration, test the search:
|
||||||
|
|
||||||
|
1. Open the app and navigate to Search
|
||||||
|
2. Try searching for:
|
||||||
|
- A username (e.g., "john")
|
||||||
|
- A hashtag (e.g., "#nature")
|
||||||
|
- Any word from a post body (e.g., "wellness")
|
||||||
|
3. Click on a hashtag in a post - it should navigate to search with results
|
||||||
|
|
||||||
|
## Rollback (if needed)
|
||||||
|
|
||||||
|
If you need to revert, run this SQL:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE OR REPLACE FUNCTION search_sojorn(p_query TEXT, limit_count INTEGER DEFAULT 10)
|
||||||
|
RETURNS JSON LANGUAGE plpgsql STABLE AS $$
|
||||||
|
DECLARE result JSON;
|
||||||
|
BEGIN
|
||||||
|
SELECT json_build_object(
|
||||||
|
'users', (SELECT json_agg(json_build_object('id', p.id, 'username', p.handle, 'display_name', p.display_name, 'avatar_url', p.avatar_url, 'harmony_tier', COALESCE(ts.tier, 'new')))
|
||||||
|
FROM profiles p LEFT JOIN trust_state ts ON p.id = ts.user_id WHERE p.handle ILIKE '%' || p_query || '%' LIMIT limit_count),
|
||||||
|
'tags', (SELECT json_agg(json_build_object('tag', tag, 'count', cnt)) FROM (
|
||||||
|
SELECT LOWER(UNNEST(tags)) AS tag, COUNT(*) AS cnt FROM posts WHERE tags IS NOT NULL AND deleted_at IS NULL
|
||||||
|
GROUP BY tag HAVING LOWER(tag) LIKE '%' || LOWER(p_query) || '%' ORDER BY cnt DESC LIMIT limit_count) t)
|
||||||
|
) INTO result;
|
||||||
|
RETURN result;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: This removes the posts search capability.
|
||||||
76
_legacy/supabase/apply-migration.ps1
Normal file
76
_legacy/supabase/apply-migration.ps1
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
# PowerShell script to apply database migration via Supabase CLI
|
||||||
|
# This connects to your remote Supabase project and applies the search function update
|
||||||
|
|
||||||
|
$PROJECT_REF = "zwkihedetedlatyvplyz"
|
||||||
|
$MIGRATION_FILE = "migrations/update_search_function.sql"
|
||||||
|
|
||||||
|
Write-Host "=====================================" -ForegroundColor Cyan
|
||||||
|
Write-Host "Sojorn Database Migration" -ForegroundColor Cyan
|
||||||
|
Write-Host "=====================================" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "This will update the search_sojorn() function to enable:" -ForegroundColor Yellow
|
||||||
|
Write-Host " - Full-text search in post bodies" -ForegroundColor White
|
||||||
|
Write-Host " - User search by display name AND handle" -ForegroundColor White
|
||||||
|
Write-Host " - Hashtag search with post results" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Check if Supabase CLI is installed
|
||||||
|
if (-not (Get-Command supabase -ErrorAction SilentlyContinue)) {
|
||||||
|
Write-Host "ERROR: Supabase CLI is not installed or not in PATH" -ForegroundColor Red
|
||||||
|
Write-Host "Install via: scoop install supabase" -ForegroundColor Yellow
|
||||||
|
Write-Host "Or visit: https://supabase.com/docs/guides/cli" -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Step 1: Linking to Supabase project..." -ForegroundColor Green
|
||||||
|
Write-Host "Project Reference: $PROJECT_REF" -ForegroundColor White
|
||||||
|
|
||||||
|
# Link to project (will prompt for database password if needed)
|
||||||
|
$linkResult = supabase link --project-ref $PROJECT_REF 2>&1
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "ERROR: Failed to link to Supabase project" -ForegroundColor Red
|
||||||
|
Write-Host "Please ensure you have the correct project reference and database password" -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Alternative: Apply via Supabase Dashboard" -ForegroundColor Cyan
|
||||||
|
Write-Host "1. Go to: https://app.supabase.com/project/$PROJECT_REF/sql" -ForegroundColor White
|
||||||
|
Write-Host "2. Copy contents of: $MIGRATION_FILE" -ForegroundColor White
|
||||||
|
Write-Host "3. Paste and run in SQL Editor" -ForegroundColor White
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Step 2: Applying migration..." -ForegroundColor Green
|
||||||
|
Write-Host "Reading: $MIGRATION_FILE" -ForegroundColor White
|
||||||
|
|
||||||
|
# Read the migration SQL
|
||||||
|
if (-not (Test-Path $MIGRATION_FILE)) {
|
||||||
|
Write-Host "ERROR: Migration file not found: $MIGRATION_FILE" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = Get-Content $MIGRATION_FILE -Raw
|
||||||
|
|
||||||
|
# Apply the migration
|
||||||
|
Write-Host "Executing SQL..." -ForegroundColor White
|
||||||
|
$result = $sql | supabase db execute 2>&1
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "SUCCESS! Migration applied successfully" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Next steps:" -ForegroundColor Cyan
|
||||||
|
Write-Host "1. Test the search functionality in your app" -ForegroundColor White
|
||||||
|
Write-Host "2. Search for users, hashtags, and words in posts" -ForegroundColor White
|
||||||
|
Write-Host "3. Click hashtags in posts to navigate to search" -ForegroundColor White
|
||||||
|
} else {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "ERROR: Migration failed" -ForegroundColor Red
|
||||||
|
Write-Host "Error output:" -ForegroundColor Yellow
|
||||||
|
Write-Host $result
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Try applying manually via Supabase Dashboard:" -ForegroundColor Cyan
|
||||||
|
Write-Host "https://app.supabase.com/project/$PROJECT_REF/sql" -ForegroundColor White
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@ echo "=== Sojorn Admin Panel Server Deployment ==="
|
||||||
|
|
||||||
# 1. Run DB migration
|
# 1. Run DB migration
|
||||||
echo "--- Running DB migration ---"
|
echo "--- Running DB migration ---"
|
||||||
export PGPASSWORD="${PGPASSWORD:?Set PGPASSWORD before running this script}"
|
export PGPASSWORD='A24Zr7AEoch4eO0N'
|
||||||
|
|
||||||
psql -U postgres -h localhost -d sojorn <<'EOSQL'
|
psql -U postgres -h localhost -d sojorn <<'EOSQL'
|
||||||
-- Algorithm configuration table
|
-- Algorithm configuration table
|
||||||
|
|
@ -107,8 +107,8 @@ echo "--- DB migration complete ---"
|
||||||
echo "--- Checking Node.js ---"
|
echo "--- Checking Node.js ---"
|
||||||
if ! command -v node &> /dev/null; then
|
if ! command -v node &> /dev/null; then
|
||||||
echo "Installing Node.js 20..."
|
echo "Installing Node.js 20..."
|
||||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo bash -
|
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -S bash -
|
||||||
sudo apt-get install -y nodejs
|
echo 'P22k154ever!' | sudo -S apt-get install -y nodejs
|
||||||
fi
|
fi
|
||||||
echo "Node: $(node --version), npm: $(npm --version)"
|
echo "Node: $(node --version), npm: $(npm --version)"
|
||||||
|
|
||||||
|
|
@ -125,9 +125,9 @@ echo "Go backend built successfully"
|
||||||
|
|
||||||
# 5. Restart Go backend
|
# 5. Restart Go backend
|
||||||
echo "--- Restarting Go backend ---"
|
echo "--- Restarting Go backend ---"
|
||||||
sudo systemctl restart sojorn-api
|
echo 'P22k154ever!' | sudo -S systemctl restart sojorn-api
|
||||||
sleep 3
|
sleep 3
|
||||||
sudo systemctl status sojorn-api --no-pager || true
|
echo 'P22k154ever!' | sudo -S systemctl status sojorn-api --no-pager || true
|
||||||
|
|
||||||
# 6. Setup admin frontend
|
# 6. Setup admin frontend
|
||||||
echo "--- Setting up admin frontend ---"
|
echo "--- Setting up admin frontend ---"
|
||||||
|
|
@ -152,7 +152,7 @@ NEXT_PUBLIC_API_URL=https://api.sojorn.net
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# 8. Create systemd service for admin
|
# 8. Create systemd service for admin
|
||||||
sudo tee /etc/systemd/system/sojorn-admin.service > /dev/null <<'EOF'
|
echo 'P22k154ever!' | sudo -S tee /etc/systemd/system/sojorn-admin.service > /dev/null <<'EOF'
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Sojorn Admin Panel
|
Description=Sojorn Admin Panel
|
||||||
After=network.target sojorn-api.service
|
After=network.target sojorn-api.service
|
||||||
|
|
@ -172,15 +172,15 @@ EnvironmentFile=/opt/sojorn/admin/.env.local
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
sudo systemctl daemon-reload
|
echo 'P22k154ever!' | sudo -S systemctl daemon-reload
|
||||||
sudo systemctl enable sojorn-admin
|
echo 'P22k154ever!' | sudo -S systemctl enable sojorn-admin
|
||||||
sudo systemctl restart sojorn-admin
|
echo 'P22k154ever!' | sudo -S systemctl restart sojorn-admin
|
||||||
sleep 3
|
sleep 3
|
||||||
sudo systemctl status sojorn-admin --no-pager || true
|
echo 'P22k154ever!' | sudo -S systemctl status sojorn-admin --no-pager || true
|
||||||
|
|
||||||
# 9. Setup Nginx
|
# 9. Setup Nginx
|
||||||
echo "--- Setting up Nginx for admin ---"
|
echo "--- Setting up Nginx for admin ---"
|
||||||
sudo tee /etc/nginx/sites-available/sojorn-admin > /dev/null <<'EOF'
|
echo 'P22k154ever!' | sudo -S tee /etc/nginx/sites-available/sojorn-admin > /dev/null <<'EOF'
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name admin.sojorn.net;
|
server_name admin.sojorn.net;
|
||||||
|
|
@ -201,11 +201,11 @@ EOF
|
||||||
|
|
||||||
# Enable site if not already
|
# Enable site if not already
|
||||||
if [ ! -L /etc/nginx/sites-enabled/sojorn-admin ]; then
|
if [ ! -L /etc/nginx/sites-enabled/sojorn-admin ]; then
|
||||||
sudo ln -s /etc/nginx/sites-available/sojorn-admin /etc/nginx/sites-enabled/
|
echo 'P22k154ever!' | sudo -S ln -s /etc/nginx/sites-available/sojorn-admin /etc/nginx/sites-enabled/
|
||||||
fi
|
fi
|
||||||
|
|
||||||
sudo nginx -t
|
echo 'P22k154ever!' | sudo -S nginx -t
|
||||||
sudo systemctl reload nginx
|
echo 'P22k154ever!' | sudo -S systemctl reload nginx
|
||||||
|
|
||||||
echo "=== Deployment complete! ==="
|
echo "=== Deployment complete! ==="
|
||||||
echo "Admin panel running on port 3001"
|
echo "Admin panel running on port 3001"
|
||||||
|
|
@ -214,4 +214,4 @@ echo ""
|
||||||
echo "NEXT STEPS:"
|
echo "NEXT STEPS:"
|
||||||
echo "1. Point admin.sojorn.net DNS A record to this server IP"
|
echo "1. Point admin.sojorn.net DNS A record to this server IP"
|
||||||
echo "2. Run: sudo certbot --nginx -d admin.sojorn.net"
|
echo "2. Run: sudo certbot --nginx -d admin.sojorn.net"
|
||||||
echo "3. Set an admin user: psql -U postgres -h localhost -d sojorn -c \"UPDATE profiles SET role = 'admin' WHERE handle = 'your_handle';\""
|
echo "3. Set an admin user: PGPASSWORD='A24Zr7AEoch4eO0N' psql -U postgres -h localhost -d sojorn -c \"UPDATE profiles SET role = 'admin' WHERE handle = 'your_handle';\""
|
||||||
|
|
|
||||||
542
admin/docs/ADMIN_SYSTEM.md
Normal file
542
admin/docs/ADMIN_SYSTEM.md
Normal file
|
|
@ -0,0 +1,542 @@
|
||||||
|
# Sojorn Admin Panel — Comprehensive System Documentation
|
||||||
|
|
||||||
|
> Last updated: February 6, 2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Overview](#overview)
|
||||||
|
2. [Architecture](#architecture)
|
||||||
|
3. [Authentication & Security](#authentication--security)
|
||||||
|
4. [Server Deployment](#server-deployment)
|
||||||
|
5. [Frontend (Next.js)](#frontend-nextjs)
|
||||||
|
6. [Backend API Routes](#backend-api-routes)
|
||||||
|
7. [Database Schema](#database-schema)
|
||||||
|
8. [Feature Reference](#feature-reference)
|
||||||
|
9. [Environment Variables](#environment-variables)
|
||||||
|
10. [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Sojorn Admin Panel is an internal tool for platform administrators to manage users, moderate content, review appeals, configure the feed algorithm, and monitor system health. It is a standalone Next.js 14 application that communicates with the existing Sojorn Go backend via a dedicated set of admin API endpoints.
|
||||||
|
|
||||||
|
**Key characteristics:**
|
||||||
|
- Separate frontend deployment from the main Flutter app
|
||||||
|
- Role-based access — only users with `role = 'admin'` in the `profiles` table can log in
|
||||||
|
- All admin actions are logged to the `audit_log` table
|
||||||
|
- Invisible Cloudflare Turnstile bot protection on login
|
||||||
|
- JWT authentication with 24-hour token expiry
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────┐ HTTPS ┌──────────────────────┐
|
||||||
|
│ Browser │ ◄────────────► │ Nginx │
|
||||||
|
│ admin.sojorn.net │ │ (reverse proxy) │
|
||||||
|
└─────────────────────┘ └──────┬───────────────┘
|
||||||
|
│
|
||||||
|
┌──────────────────┼──────────────────┐
|
||||||
|
│ port 3002 │ port 8080 │
|
||||||
|
▼ ▼ │
|
||||||
|
┌─────────────────┐ ┌─────────────────┐ │
|
||||||
|
│ Next.js 14 │ │ Go Backend │ │
|
||||||
|
│ (sojorn-admin) │ │ (sojorn-api) │ │
|
||||||
|
│ SSR + Static │ │ Gin framework │ │
|
||||||
|
└─────────────────┘ └────────┬────────┘ │
|
||||||
|
│ │
|
||||||
|
┌────────▼────────┐ │
|
||||||
|
│ PostgreSQL │ │
|
||||||
|
│ sojorn database │ │
|
||||||
|
└─────────────────┘ │
|
||||||
|
│
|
||||||
|
┌─────────────────┐ │
|
||||||
|
│ Cloudflare R2 │───────┘
|
||||||
|
│ (media storage) │
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
|
||||||
|
| Component | Technology |
|
||||||
|
|-----------|-----------|
|
||||||
|
| Frontend | Next.js 14, TypeScript, TailwindCSS, Recharts, Lucide icons |
|
||||||
|
| Backend | Go 1.21+, Gin framework, pgx (PostgreSQL driver) |
|
||||||
|
| Database | PostgreSQL 15+ |
|
||||||
|
| Process management | systemd |
|
||||||
|
| Reverse proxy | Nginx |
|
||||||
|
| Bot protection | Cloudflare Turnstile (invisible mode) |
|
||||||
|
| Auth | JWT (HS256), bcrypt password hashing |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication & Security
|
||||||
|
|
||||||
|
### Login Flow
|
||||||
|
|
||||||
|
1. User enters email + password on `/login`
|
||||||
|
2. Cloudflare Turnstile invisible widget generates a token in the background
|
||||||
|
3. Frontend sends `POST /api/v1/admin/login` with `{ email, password, turnstile_token }`
|
||||||
|
4. Backend verifies Turnstile token with Cloudflare API
|
||||||
|
5. Backend checks `users` table for valid credentials (bcrypt)
|
||||||
|
6. Backend verifies account status is `active`
|
||||||
|
7. Backend checks `profiles.role = 'admin'`
|
||||||
|
8. Returns JWT (`access_token`) with 24-hour expiry + user profile data
|
||||||
|
9. Token stored in `localStorage` as `admin_token`
|
||||||
|
|
||||||
|
### Middleware Chain (Protected Routes)
|
||||||
|
|
||||||
|
All `/api/v1/admin/*` routes (except `/login`) pass through:
|
||||||
|
|
||||||
|
1. **AuthMiddleware** — Validates JWT, extracts `user_id` from `sub` claim, sets it in Gin context
|
||||||
|
2. **AdminMiddleware** — Queries `profiles.role` for the authenticated user, rejects non-admin users with 403
|
||||||
|
|
||||||
|
### Security Measures
|
||||||
|
|
||||||
|
- **Invisible Turnstile** — Blocks automated login attacks without user friction
|
||||||
|
- **Graceful degradation** — If `TURNSTILE_SECRET` is empty, verification is skipped (dev mode)
|
||||||
|
- **bcrypt** — Passwords hashed with bcrypt (default cost)
|
||||||
|
- **JWT expiry** — Admin tokens expire after 24 hours (vs 7 days for regular users)
|
||||||
|
- **Auto-logout** — Frontend redirects to `/login` on any 401 response
|
||||||
|
- **Audit logging** — Status changes, post deletions, and moderation actions are logged
|
||||||
|
|
||||||
|
### Granting Admin Access
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE profiles SET role = 'admin' WHERE handle = 'your_handle';
|
||||||
|
```
|
||||||
|
|
||||||
|
Valid roles: `user`, `moderator`, `admin`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server Deployment
|
||||||
|
|
||||||
|
### Services
|
||||||
|
|
||||||
|
| Service | systemd unit | Port | Binary/Entry |
|
||||||
|
|---------|-------------|------|-------------|
|
||||||
|
| Go API | `sojorn-api` | 8080 | `/opt/sojorn/bin/api` |
|
||||||
|
| Admin Frontend | `sojorn-admin` | 3002 | `node .../next start --port 3002` |
|
||||||
|
|
||||||
|
### File Locations on Server
|
||||||
|
|
||||||
|
```
|
||||||
|
/opt/sojorn/
|
||||||
|
├── .env # Shared environment variables
|
||||||
|
├── bin/
|
||||||
|
│ └── api # Compiled Go binary
|
||||||
|
├── go-backend/ # Go source code
|
||||||
|
│ ├── cmd/api/main.go
|
||||||
|
│ ├── internal/
|
||||||
|
│ │ ├── handlers/admin_handler.go
|
||||||
|
│ │ ├── middleware/admin.go
|
||||||
|
│ │ └── ...
|
||||||
|
│ └── .env # Symlink or copy of /opt/sojorn/.env
|
||||||
|
├── admin/ # Next.js admin frontend
|
||||||
|
│ ├── .env.local # Frontend env vars
|
||||||
|
│ ├── .next/ # Build output
|
||||||
|
│ ├── node_modules/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── app/ # Page routes
|
||||||
|
│ │ └── lib/ # API client, auth context
|
||||||
|
│ └── package.json
|
||||||
|
└── firebase-service-account.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### systemd Service Files
|
||||||
|
|
||||||
|
**`/etc/systemd/system/sojorn-admin.service`**:
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Sojorn Admin Panel
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=patrick
|
||||||
|
Group=patrick
|
||||||
|
WorkingDirectory=/opt/sojorn/admin
|
||||||
|
ExecStart=/usr/bin/node /opt/sojorn/admin/node_modules/next/dist/bin/next start --port 3002
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=30
|
||||||
|
StartLimitIntervalSec=120
|
||||||
|
StartLimitBurst=3
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
Environment=NEXT_PUBLIC_API_URL=https://api.sojorn.net
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nginx Configuration
|
||||||
|
|
||||||
|
**`/etc/nginx/sites-available/sojorn-admin`**:
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name admin.sojorn.net;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3002;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Operations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Rebuild and deploy Go backend
|
||||||
|
cd /opt/sojorn/go-backend
|
||||||
|
go build -ldflags='-s -w' -o /opt/sojorn/bin/api ./cmd/api/main.go
|
||||||
|
sudo systemctl restart sojorn-api
|
||||||
|
|
||||||
|
# Rebuild and deploy admin frontend
|
||||||
|
cd /opt/sojorn/admin
|
||||||
|
npx next build
|
||||||
|
sudo systemctl restart sojorn-admin
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
sudo journalctl -u sojorn-admin -f
|
||||||
|
sudo journalctl -u sojorn-api -f
|
||||||
|
|
||||||
|
# Check service status
|
||||||
|
sudo systemctl status sojorn-admin
|
||||||
|
sudo systemctl status sojorn-api
|
||||||
|
|
||||||
|
# SSL certificate (after DNS A record is pointed)
|
||||||
|
sudo certbot --nginx -d admin.sojorn.net
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend (Next.js)
|
||||||
|
|
||||||
|
### Pages
|
||||||
|
|
||||||
|
| Route | File | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `/` | `app/page.tsx` | Dashboard with stats cards and growth charts |
|
||||||
|
| `/login` | `app/login/page.tsx` | Admin login with invisible Turnstile |
|
||||||
|
| `/users` | `app/users/page.tsx` | User list with search, filter by status/role |
|
||||||
|
| `/users/[id]` | `app/users/[id]/page.tsx` | User detail — profile, stats, admin actions |
|
||||||
|
| `/posts` | `app/posts/page.tsx` | Post list with search, filter by status |
|
||||||
|
| `/posts/[id]` | `app/posts/[id]/page.tsx` | Post detail — content, media, moderation flags, admin actions |
|
||||||
|
| `/moderation` | `app/moderation/page.tsx` | Moderation queue — AI-flagged content pending review |
|
||||||
|
| `/appeals` | `app/appeals/page.tsx` | User appeals against violations |
|
||||||
|
| `/reports` | `app/reports/page.tsx` | User-submitted reports |
|
||||||
|
| `/algorithm` | `app/algorithm/page.tsx` | Feed algorithm & moderation threshold tuning |
|
||||||
|
| `/categories` | `app/categories/page.tsx` | Category management — create, edit, toggle sensitive |
|
||||||
|
| `/system` | `app/system/page.tsx` | System health, DB stats, audit log |
|
||||||
|
| `/settings` | `app/settings/page.tsx` | Admin session info, API URL override |
|
||||||
|
|
||||||
|
### Key Libraries
|
||||||
|
|
||||||
|
| Library | Source |
|
||||||
|
|---------|--------|
|
||||||
|
| `api.ts` | `src/lib/api.ts` — Singleton API client with JWT token management |
|
||||||
|
| `auth.tsx` | `src/lib/auth.tsx` — React context provider for auth state |
|
||||||
|
|
||||||
|
### API Client (`src/lib/api.ts`)
|
||||||
|
|
||||||
|
The `ApiClient` class provides typed methods for every admin API endpoint. Key behaviors:
|
||||||
|
|
||||||
|
- **Token management** — Stored in `localStorage` as `admin_token`, attached as `Bearer` header
|
||||||
|
- **Auto-logout** — Any 401 response clears token and redirects to `/login`
|
||||||
|
- **Base URL** — Configurable via `NEXT_PUBLIC_API_URL` environment variable
|
||||||
|
- **Error handling** — Throws `Error` with server-provided error message
|
||||||
|
|
||||||
|
### Auth Context (`src/lib/auth.tsx`)
|
||||||
|
|
||||||
|
- Wraps app in `AuthProvider`
|
||||||
|
- On mount, validates existing token by calling `getDashboardStats()`
|
||||||
|
- Provides `login()`, `logout()`, `isAuthenticated`, `isLoading`, `user`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend API Routes
|
||||||
|
|
||||||
|
All routes prefixed with `/api/v1/admin/`.
|
||||||
|
|
||||||
|
### Authentication (no middleware)
|
||||||
|
|
||||||
|
| Method | Path | Handler | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `POST` | `/login` | `AdminLogin` | Email/password login with Turnstile verification |
|
||||||
|
|
||||||
|
### Dashboard (requires auth + admin)
|
||||||
|
|
||||||
|
| Method | Path | Handler | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `GET` | `/dashboard` | `GetDashboardStats` | User/post/moderation/appeal/report counts |
|
||||||
|
| `GET` | `/growth?days=30` | `GetGrowthStats` | Daily user & post creation counts for charts |
|
||||||
|
|
||||||
|
### User Management
|
||||||
|
|
||||||
|
| Method | Path | Handler | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `GET` | `/users?limit=50&offset=0&search=&status=&role=` | `ListUsers` | Paginated user list with filters |
|
||||||
|
| `GET` | `/users/:id` | `GetUser` | Full user profile with follower/post/violation counts |
|
||||||
|
| `PATCH` | `/users/:id/status` | `UpdateUserStatus` | Set status: `active`, `suspended`, `banned`, `deactivated` |
|
||||||
|
| `PATCH` | `/users/:id/role` | `UpdateUserRole` | Set role: `user`, `moderator`, `admin` |
|
||||||
|
| `PATCH` | `/users/:id/verification` | `UpdateUserVerification` | Toggle `is_official` and `is_verified` flags |
|
||||||
|
| `POST` | `/users/:id/reset-strikes` | `ResetUserStrikes` | Reset violation strike counter to 0 |
|
||||||
|
|
||||||
|
### Post Management
|
||||||
|
|
||||||
|
| Method | Path | Handler | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `GET` | `/posts?limit=50&offset=0&search=&status=&author_id=` | `ListPosts` | Paginated post list with filters |
|
||||||
|
| `GET` | `/posts/:id` | `GetPost` | Full post detail with moderation flags |
|
||||||
|
| `PATCH` | `/posts/:id/status` | `UpdatePostStatus` | Set status: `active`, `flagged`, `removed` |
|
||||||
|
| `DELETE` | `/posts/:id` | `DeletePost` | Soft-delete (sets `deleted_at` + status `removed`) |
|
||||||
|
|
||||||
|
### Moderation Queue
|
||||||
|
|
||||||
|
| Method | Path | Handler | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `GET` | `/moderation?limit=50&offset=0&status=pending` | `GetModerationQueue` | AI-flagged content awaiting review |
|
||||||
|
| `PATCH` | `/moderation/:id/review` | `ReviewModerationFlag` | Actions: `approve`, `dismiss`, `remove_content`, `ban_user` |
|
||||||
|
|
||||||
|
### Appeals
|
||||||
|
|
||||||
|
| Method | Path | Handler | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `GET` | `/appeals?limit=50&offset=0&status=pending` | `ListAppeals` | User appeals with violation details |
|
||||||
|
| `PATCH` | `/appeals/:id/review` | `ReviewAppeal` | Decision: `approved` or `rejected`, optional content restore |
|
||||||
|
|
||||||
|
### Reports
|
||||||
|
|
||||||
|
| Method | Path | Handler | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `GET` | `/reports?limit=50&offset=0&status=pending` | `ListReports` | User-submitted reports |
|
||||||
|
| `PATCH` | `/reports/:id` | `UpdateReportStatus` | Set status: `reviewed`, `dismissed`, `actioned` |
|
||||||
|
|
||||||
|
### Algorithm & Feed Config
|
||||||
|
|
||||||
|
| Method | Path | Handler | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `GET` | `/algorithm` | `GetAlgorithmConfig` | All key-value config pairs |
|
||||||
|
| `PUT` | `/algorithm` | `UpdateAlgorithmConfig` | Upsert a config `{ key, value }` |
|
||||||
|
|
||||||
|
### Categories
|
||||||
|
|
||||||
|
| Method | Path | Handler | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `GET` | `/categories` | `ListCategories` | All content categories |
|
||||||
|
| `POST` | `/categories` | `CreateCategory` | Create `{ slug, name, description?, is_sensitive? }` |
|
||||||
|
| `PATCH` | `/categories/:id` | `UpdateCategory` | Update name, description, or sensitive flag |
|
||||||
|
|
||||||
|
### System
|
||||||
|
|
||||||
|
| Method | Path | Handler | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `GET` | `/health` | `GetSystemHealth` | DB ping, latency, connection pool stats, DB size |
|
||||||
|
| `GET` | `/audit-log?limit=50&offset=0` | `GetAuditLog` | Admin action history with actor handles |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### Tables Created by Admin Migration
|
||||||
|
|
||||||
|
**`algorithm_config`** — Key-value store for feed and moderation tuning:
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `key` | `TEXT PRIMARY KEY` | Config identifier |
|
||||||
|
| `value` | `TEXT NOT NULL` | Config value |
|
||||||
|
| `description` | `TEXT` | Human-readable description |
|
||||||
|
| `updated_at` | `TIMESTAMPTZ` | Last modification time |
|
||||||
|
|
||||||
|
Default seed values:
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
|-----|---------|-------------|
|
||||||
|
| `feed_recency_weight` | `0.4` | Weight for post recency in feed ranking |
|
||||||
|
| `feed_engagement_weight` | `0.3` | Weight for engagement metrics |
|
||||||
|
| `feed_harmony_weight` | `0.2` | Weight for author harmony/trust score |
|
||||||
|
| `feed_diversity_weight` | `0.1` | Weight for content diversity |
|
||||||
|
| `moderation_auto_flag_threshold` | `0.7` | AI score threshold for auto-flagging |
|
||||||
|
| `moderation_auto_remove_threshold` | `0.95` | AI score threshold for auto-removal |
|
||||||
|
| `moderation_greed_keyword_threshold` | `0.7` | Spam/greed detection threshold |
|
||||||
|
| `feed_max_posts_per_author` | `3` | Max posts from same author per feed page |
|
||||||
|
| `feed_boost_mutual_follow` | `1.5` | Boost multiplier for mutual follows |
|
||||||
|
| `feed_beacon_boost` | `1.2` | Boost multiplier for beacon posts |
|
||||||
|
|
||||||
|
**`audit_log`** — Admin action history:
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `id` | `UUID PRIMARY KEY` | Unique entry ID |
|
||||||
|
| `actor_id` | `UUID` | Admin who performed the action |
|
||||||
|
| `action` | `TEXT NOT NULL` | Action type (e.g., `post_status_change`, `admin_delete_post`) |
|
||||||
|
| `target_type` | `TEXT NOT NULL` | Entity type: `user`, `post`, `comment`, `appeal`, `report`, `config` |
|
||||||
|
| `target_id` | `UUID` | ID of the affected entity |
|
||||||
|
| `details` | `TEXT` | JSON string with action-specific metadata |
|
||||||
|
| `created_at` | `TIMESTAMPTZ` | Timestamp |
|
||||||
|
|
||||||
|
### Columns Ensured by Migration
|
||||||
|
|
||||||
|
The migration ensures these columns exist (added if missing):
|
||||||
|
|
||||||
|
| Table | Column | Type | Default |
|
||||||
|
|-------|--------|------|---------|
|
||||||
|
| `profiles` | `role` | `TEXT` | `'user'` |
|
||||||
|
| `profiles` | `is_verified` | `BOOLEAN` | `FALSE` |
|
||||||
|
| `profiles` | `is_private` | `BOOLEAN` | `FALSE` |
|
||||||
|
| `users` | `status` | `TEXT` | `'active'` |
|
||||||
|
| `users` | `last_login` | `TIMESTAMPTZ` | `NULL` |
|
||||||
|
|
||||||
|
### Pre-existing Tables Used by Admin
|
||||||
|
|
||||||
|
| Table | Admin Usage |
|
||||||
|
|-------|-------------|
|
||||||
|
| `users` | Login validation, status management, growth stats |
|
||||||
|
| `profiles` | Role checks, user details, verification flags |
|
||||||
|
| `posts` | Content listing, status changes, deletion |
|
||||||
|
| `comments` | Moderation flag targets, appeal content restoration |
|
||||||
|
| `moderation_flags` | Moderation queue — AI-generated flags with scores |
|
||||||
|
| `user_violations` | Violation records linked to moderation flags |
|
||||||
|
| `user_appeals` | Appeal records linked to violations |
|
||||||
|
| `user_status_history` | Log of admin-initiated status changes |
|
||||||
|
| `reports` | User-submitted reports of other users/content |
|
||||||
|
| `categories` | Content categories managed by admins |
|
||||||
|
| `follows` | Follower/following counts on user detail page |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Reference
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
|
||||||
|
Displays real-time aggregate stats:
|
||||||
|
- **Users**: total, active, suspended, banned, new today
|
||||||
|
- **Posts**: total, active, flagged, removed, new today
|
||||||
|
- **Moderation**: pending flags, reviewed flags
|
||||||
|
- **Appeals**: pending, approved, rejected
|
||||||
|
- **Reports**: pending count
|
||||||
|
- **Growth charts**: Daily user & post registrations (configurable 7/30/90 day window)
|
||||||
|
|
||||||
|
### Moderation Workflow
|
||||||
|
|
||||||
|
1. **AI flagging** — Posts/comments are automatically analyzed by the `ModerationService` using OpenAI + Google Vision
|
||||||
|
2. **Three Poisons Score** — Content is scored on Hate, Greed, Delusion dimensions
|
||||||
|
3. **Auto-flag** — Content exceeding `moderation_auto_flag_threshold` is flagged for review
|
||||||
|
4. **Admin review** — Admin sees flagged content in the moderation queue with scores
|
||||||
|
5. **Actions available**:
|
||||||
|
- **Approve** — Content is fine, dismiss the flag
|
||||||
|
- **Dismiss** — Same as approve (flag was a false positive)
|
||||||
|
- **Remove content** — Soft-delete the post/comment
|
||||||
|
- **Ban user** — Ban the author and action the flag
|
||||||
|
|
||||||
|
### Appeal Workflow
|
||||||
|
|
||||||
|
1. User receives a violation (triggered by moderation)
|
||||||
|
2. User submits an appeal with reason and context
|
||||||
|
3. Appeal appears in admin panel with violation details, AI scores, and original content
|
||||||
|
4. Admin reviews and decides:
|
||||||
|
- **Approve** — Optionally restore the removed content
|
||||||
|
- **Reject** — Violation stands, include written reasoning (min 5 chars)
|
||||||
|
|
||||||
|
### User Management Actions
|
||||||
|
|
||||||
|
- **Change status**: `active` ↔ `suspended` ↔ `banned` ↔ `deactivated` (requires reason)
|
||||||
|
- **Change role**: `user` ↔ `moderator` ↔ `admin`
|
||||||
|
- **Toggle verification**: `is_official` and `is_verified` badges
|
||||||
|
- **Reset strikes**: Clear the violation counter
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
### Frontend (`/opt/sojorn/admin/.env.local`)
|
||||||
|
|
||||||
|
| Variable | Value | Description |
|
||||||
|
|----------|-------|-------------|
|
||||||
|
| `NEXT_PUBLIC_API_URL` | `https://api.sojorn.net` | Go backend base URL |
|
||||||
|
| `NEXT_PUBLIC_TURNSTILE_SITE_KEY` | `0x4AAAAAAC...` | Cloudflare Turnstile site key (invisible mode) |
|
||||||
|
|
||||||
|
### Backend (`/opt/sojorn/.env`)
|
||||||
|
|
||||||
|
The admin system uses these existing environment variables:
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `DATABASE_URL` | PostgreSQL connection string |
|
||||||
|
| `JWT_SECRET` | Secret for signing/verifying JWT tokens |
|
||||||
|
| `TURNSTILE_SECRET` | Cloudflare Turnstile server-side verification key |
|
||||||
|
| `PORT` | API server port (default: `8080`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Admin login returns "Admin access required"
|
||||||
|
|
||||||
|
The user's profile doesn't have `role = 'admin'`. Fix:
|
||||||
|
```sql
|
||||||
|
UPDATE profiles SET role = 'admin' WHERE handle = 'your_handle';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Admin login returns "Invalid credentials"
|
||||||
|
|
||||||
|
- Verify the email matches what's in the `users` table
|
||||||
|
- Password is validated against `encrypted_password` via bcrypt
|
||||||
|
- Check the user's `status` is `active` (not `pending`, `suspended`, or `banned`)
|
||||||
|
|
||||||
|
### Admin login returns "Security verification failed"
|
||||||
|
|
||||||
|
Cloudflare Turnstile rejected the request. Possible causes:
|
||||||
|
- `TURNSTILE_SECRET` in backend `.env` doesn't match the site key in frontend `.env.local`
|
||||||
|
- The Turnstile widget hostname doesn't include `admin.sojorn.net` in Cloudflare dashboard
|
||||||
|
- Bot or automated request without a valid Turnstile token
|
||||||
|
|
||||||
|
### sojorn-admin service won't start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check logs
|
||||||
|
sudo journalctl -u sojorn-admin -n 50 --no-pager
|
||||||
|
|
||||||
|
# Check if port 3002 is in use
|
||||||
|
ss -tlnp | grep 3002
|
||||||
|
|
||||||
|
# If port is taken, find and kill the process
|
||||||
|
sudo fuser -k 3002/tcp
|
||||||
|
sudo systemctl restart sojorn-admin
|
||||||
|
```
|
||||||
|
|
||||||
|
### sojorn-api panics on startup
|
||||||
|
|
||||||
|
Check for duplicate route registrations in `cmd/api/main.go`. The Go backend will panic if two routes resolve to the same path (e.g., legacy routes conflicting with admin group routes).
|
||||||
|
|
||||||
|
### Frontend shows "Loading..." indefinitely
|
||||||
|
|
||||||
|
- Check browser console for network errors
|
||||||
|
- Verify `NEXT_PUBLIC_API_URL` in `.env.local` is correct and reachable
|
||||||
|
- Ensure the Go API is running: `sudo systemctl status sojorn-api`
|
||||||
|
- Check CORS — the backend must allow the admin domain in `CORS_ORIGINS`
|
||||||
|
|
||||||
|
### Database migration errors
|
||||||
|
|
||||||
|
Run the migration manually:
|
||||||
|
```bash
|
||||||
|
export PGPASSWORD=your_db_password
|
||||||
|
psql -U postgres -h localhost -d sojorn -f /opt/sojorn/go-backend/internal/database/migrations/20260206000001_admin_panel_tables.up.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### PM2 conflicts
|
||||||
|
|
||||||
|
Port 3001 is used by another PM2-managed site on this server. The admin panel uses port **3002** to avoid conflicts. Do not change it to 3001.
|
||||||
|
|
@ -3,35 +3,19 @@
|
||||||
import AdminShell from '@/components/AdminShell';
|
import AdminShell from '@/components/AdminShell';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import { Brain, Search, Check, ChevronDown, Play, Loader2, Eye, MessageSquare, Video, Shield, MapPin, Users, AlertTriangle, Server, Cloud, Cpu, Terminal, Upload } from 'lucide-react';
|
import { Brain, Search, Check, Power, PowerOff, ChevronDown, Play, Loader2, Eye, MessageSquare, Video, Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
const MODERATION_TYPES = [
|
const MODERATION_TYPES = [
|
||||||
{ key: 'text', label: 'Text Moderation', icon: MessageSquare },
|
{ key: 'text', label: 'Text Moderation', icon: MessageSquare, desc: 'Analyze post text, comments, and captions for policy violations' },
|
||||||
{ key: 'image', label: 'Image Moderation', icon: Eye },
|
{ key: 'image', label: 'Image Moderation', icon: Eye, desc: 'Analyze uploaded images for inappropriate content (requires vision model)' },
|
||||||
{ key: 'video', label: 'Video Moderation', icon: Video },
|
{ key: 'video', label: 'Video Moderation', icon: Video, desc: 'Analyze video frames extracted from Quips (requires vision model)' },
|
||||||
{ key: 'group_text', label: 'Group Chat', icon: Users },
|
|
||||||
{ key: 'group_image', label: 'Group Image', icon: Shield },
|
|
||||||
{ key: 'beacon_text', label: 'Beacon Text', icon: MapPin },
|
|
||||||
{ key: 'beacon_image', label: 'Beacon Image', icon: AlertTriangle },
|
|
||||||
];
|
|
||||||
|
|
||||||
const ENGINES = [
|
|
||||||
{ id: 'local_ai', label: 'Local AI (Ollama)', icon: Cpu },
|
|
||||||
{ id: 'openrouter', label: 'OpenRouter', icon: Cloud },
|
|
||||||
{ id: 'openai', label: 'OpenAI', icon: Server },
|
|
||||||
{ id: 'google', label: 'Google Vision', icon: Eye },
|
|
||||||
{ id: 'azure', label: 'Azure OpenAI', icon: Cloud },
|
|
||||||
];
|
|
||||||
|
|
||||||
const LOCAL_MODELS = [
|
|
||||||
{ id: 'llama-guard3:1b', name: 'LLaMA Guard 3 (1B)' },
|
|
||||||
{ id: 'qwen2.5:7b-instruct-q4_K_M', name: 'Qwen 2.5 (7B)' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
interface ModelInfo {
|
interface ModelInfo {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
pricing: { prompt: string; completion: string };
|
description?: string;
|
||||||
|
pricing: { prompt: string; completion: string; image?: string };
|
||||||
context_length: number;
|
context_length: number;
|
||||||
architecture?: Record<string, any>;
|
architecture?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
@ -43,166 +27,25 @@ interface ModerationConfig {
|
||||||
model_name: string;
|
model_name: string;
|
||||||
system_prompt: string;
|
system_prompt: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
engines: string[];
|
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EngineInfo {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
status: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AIModerationPage() {
|
export default function AIModerationPage() {
|
||||||
const [configs, setConfigs] = useState<ModerationConfig[]>([]);
|
const [configs, setConfigs] = useState<ModerationConfig[]>([]);
|
||||||
const [engines, setEngines] = useState<EngineInfo[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [activeType, setActiveType] = useState('text');
|
||||||
// Selection states
|
|
||||||
const [selectedType, setSelectedType] = useState('text');
|
|
||||||
const [selectedEngine, setSelectedEngine] = useState('local_ai');
|
|
||||||
|
|
||||||
// Config states
|
|
||||||
const [enabled, setEnabled] = useState(false);
|
|
||||||
const [modelId, setModelId] = useState('');
|
|
||||||
const [modelName, setModelName] = useState('');
|
|
||||||
const [systemPrompt, setSystemPrompt] = useState('');
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
// OpenRouter model picker
|
|
||||||
const [showPicker, setShowPicker] = useState(false);
|
|
||||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
|
||||||
const [modelsLoading, setModelsLoading] = useState(false);
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
|
|
||||||
// Test states
|
|
||||||
const [testInput, setTestInput] = useState('');
|
|
||||||
const [testResponse, setTestResponse] = useState<any>(null);
|
|
||||||
const [testing, setTesting] = useState(false);
|
|
||||||
const [testHistory, setTestHistory] = useState<any[]>([]);
|
|
||||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
|
|
||||||
const loadConfigs = useCallback(() => {
|
const loadConfigs = useCallback(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
Promise.all([
|
api.getAIModerationConfigs()
|
||||||
api.getAIModerationConfigs(),
|
.then((data) => setConfigs(data.configs || []))
|
||||||
api.getAIEngines()
|
.catch(() => {})
|
||||||
])
|
|
||||||
.then(([configData, engineData]) => {
|
|
||||||
setConfigs(configData.configs || []);
|
|
||||||
setEngines(engineData.engines || []);
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { loadConfigs(); }, [loadConfigs]);
|
useEffect(() => { loadConfigs(); }, [loadConfigs]);
|
||||||
|
|
||||||
// Load config when type changes
|
const getConfig = (type: string) => configs.find(c => c.moderation_type === type);
|
||||||
useEffect(() => {
|
|
||||||
const config = configs.find(c => c.moderation_type === selectedType);
|
|
||||||
if (config) {
|
|
||||||
setEnabled(config.enabled);
|
|
||||||
setModelId(config.model_id || '');
|
|
||||||
setModelName(config.model_name || '');
|
|
||||||
setSystemPrompt(config.system_prompt || '');
|
|
||||||
if (config.engines && config.engines.length > 0) {
|
|
||||||
setSelectedEngine(config.engines[0]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setEnabled(false);
|
|
||||||
setModelId('');
|
|
||||||
setModelName('');
|
|
||||||
setSystemPrompt('');
|
|
||||||
}
|
|
||||||
}, [selectedType, configs]);
|
|
||||||
|
|
||||||
const loadModels = useCallback((search?: string) => {
|
|
||||||
setModelsLoading(true);
|
|
||||||
api.listOpenRouterModels({ search })
|
|
||||||
.then((data) => setModels(data.models || []))
|
|
||||||
.finally(() => setModelsLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
await api.setAIModerationConfig({
|
|
||||||
moderation_type: selectedType,
|
|
||||||
model_id: modelId,
|
|
||||||
model_name: modelName,
|
|
||||||
system_prompt: systemPrompt,
|
|
||||||
enabled,
|
|
||||||
engines: [selectedEngine],
|
|
||||||
});
|
|
||||||
loadConfigs();
|
|
||||||
} catch (e: any) {
|
|
||||||
alert(e.message);
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileUpload = async (file: File) => {
|
|
||||||
setUploading(true);
|
|
||||||
try {
|
|
||||||
const result = await api.uploadTestImage(file);
|
|
||||||
setTestInput(result.url);
|
|
||||||
setUploadedFile(file);
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error('Upload error:', e);
|
|
||||||
alert('Upload failed: ' + e.message);
|
|
||||||
} finally {
|
|
||||||
setUploading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTest = async () => {
|
|
||||||
if (!testInput.trim() && !uploadedFile) return;
|
|
||||||
setTesting(true);
|
|
||||||
const startTime = Date.now();
|
|
||||||
try {
|
|
||||||
const isImage = selectedType.includes('image') || selectedType === 'video';
|
|
||||||
const data: any = {
|
|
||||||
moderation_type: selectedType,
|
|
||||||
engine: selectedEngine,
|
|
||||||
};
|
|
||||||
if (isImage) {
|
|
||||||
data.image_url = testInput; // Use the uploaded file URL
|
|
||||||
} else {
|
|
||||||
data.content = testInput;
|
|
||||||
}
|
|
||||||
const res = await api.testAIModeration(data);
|
|
||||||
const duration = Date.now() - startTime;
|
|
||||||
const entry = { ...res, timestamp: new Date().toISOString(), duration };
|
|
||||||
setTestResponse(entry);
|
|
||||||
setTestHistory(prev => [entry, ...prev].slice(0, 10));
|
|
||||||
} catch (e: any) {
|
|
||||||
const entry = {
|
|
||||||
error: e.message,
|
|
||||||
engine: selectedEngine,
|
|
||||||
moderation_type: selectedType,
|
|
||||||
input: testInput,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
duration: Date.now() - startTime
|
|
||||||
};
|
|
||||||
setTestResponse(entry);
|
|
||||||
setTestHistory(prev => [entry, ...prev].slice(0, 10));
|
|
||||||
} finally {
|
|
||||||
setTesting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const typeLabel = MODERATION_TYPES.find(t => t.key === selectedType)?.label || selectedType;
|
|
||||||
const engineLabel = ENGINES.find(e => e.id === selectedEngine)?.label || selectedEngine;
|
|
||||||
|
|
||||||
const getEngineStatus = (id: string) => {
|
|
||||||
const engine = engines.find(e => e.id === id);
|
|
||||||
if (!engine) return { color: 'text-gray-400', dot: 'bg-gray-300', label: 'Unknown' };
|
|
||||||
if (engine.status === 'ready') return { color: 'text-green-600', dot: 'bg-green-500', label: 'Online' };
|
|
||||||
if (engine.status === 'down') return { color: 'text-red-600', dot: 'bg-red-500', label: 'Down' };
|
|
||||||
return { color: 'text-gray-400', dot: 'bg-gray-300', label: 'Not Configured' };
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminShell>
|
<AdminShell>
|
||||||
|
|
@ -210,340 +53,377 @@ export default function AIModerationPage() {
|
||||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||||
<Brain className="w-6 h-6" /> AI Moderation
|
<Brain className="w-6 h-6" /> AI Moderation
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">Configure AI moderation engines</p>
|
<p className="text-sm text-gray-500 mt-1">Configure AI models for content moderation via OpenRouter</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Engine Status - Compact */}
|
{/* Config Cards */}
|
||||||
<div className="card p-4 mb-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||||
<div className="flex items-center gap-4 text-sm">
|
{MODERATION_TYPES.map((mt) => {
|
||||||
<span className="font-semibold text-gray-700">Engine Status:</span>
|
const config = getConfig(mt.key);
|
||||||
{ENGINES.map(eng => {
|
const Icon = mt.icon;
|
||||||
const status = getEngineStatus(eng.id);
|
|
||||||
return (
|
return (
|
||||||
<div key={eng.id} className="flex items-center gap-1.5">
|
<button
|
||||||
<span className={`w-2 h-2 rounded-full ${status.dot}`} />
|
key={mt.key}
|
||||||
<span className={status.color}>{eng.label}</span>
|
onClick={() => setActiveType(mt.key)}
|
||||||
|
className={`card p-4 text-left transition-all ${
|
||||||
|
activeType === mt.key ? 'ring-2 ring-brand-500 shadow-md' : 'hover:shadow-sm'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className="w-5 h-5 text-gray-600" />
|
||||||
|
<span className="font-semibold text-gray-900 text-sm">{mt.label}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{config?.enabled ? (
|
||||||
|
<span className="flex items-center gap-1 text-xs font-medium text-green-700 bg-green-100 px-2 py-0.5 rounded-full">
|
||||||
|
<Power className="w-3 h-3" /> On
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1 text-xs font-medium text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full">
|
||||||
|
<PowerOff className="w-3 h-3" /> Off
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 mb-2">{mt.desc}</p>
|
||||||
|
{config?.model_id ? (
|
||||||
|
<p className="text-xs font-mono text-brand-600 truncate">{config.model_name || config.model_id}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-300 italic">No model selected</p>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
{/* Active Config Editor */}
|
||||||
{/* Left: Configuration */}
|
<ConfigEditor
|
||||||
<div className="space-y-4">
|
key={activeType}
|
||||||
{/* Type Selector */}
|
moderationType={activeType}
|
||||||
<div className="card p-4">
|
config={getConfig(activeType)}
|
||||||
<label className="text-sm font-semibold text-gray-700 block mb-2">Moderation Type</label>
|
onSaved={loadConfigs}
|
||||||
<select
|
|
||||||
value={selectedType}
|
|
||||||
onChange={(e) => setSelectedType(e.target.value)}
|
|
||||||
className="w-full text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
|
|
||||||
>
|
|
||||||
{MODERATION_TYPES.map(t => (
|
|
||||||
<option key={t.key} value={t.key}>{t.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Engine Selector */}
|
|
||||||
<div className="card p-4">
|
|
||||||
<label className="text-sm font-semibold text-gray-700 block mb-2">Engine</label>
|
|
||||||
<select
|
|
||||||
value={selectedEngine}
|
|
||||||
onChange={(e) => setSelectedEngine(e.target.value)}
|
|
||||||
className="w-full text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
|
|
||||||
>
|
|
||||||
{ENGINES.map(e => (
|
|
||||||
<option key={e.id} value={e.id}>{e.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* AI Moderation Instructions */}
|
|
||||||
<div className="card p-4">
|
|
||||||
<label className="text-sm font-semibold text-gray-700 block mb-2">
|
|
||||||
Moderation Instructions
|
|
||||||
</label>
|
|
||||||
<p className="text-xs text-gray-500 mb-2">
|
|
||||||
Provide specific guidelines for the AI to follow when moderating {typeLabel.toLowerCase()} content.
|
|
||||||
</p>
|
|
||||||
<textarea
|
|
||||||
rows={4}
|
|
||||||
value={systemPrompt}
|
|
||||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
|
||||||
placeholder="Example: Flag content that promotes violence, hate speech, or illegal activities. Allow political discussion and criticism. Be lenient with humor and satire..."
|
|
||||||
className="w-full text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</AdminShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{/* Engine Configuration */}
|
// ─── Config Editor for a single moderation type ─────────
|
||||||
<div className="card p-4">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h3 className="text-sm font-semibold text-gray-700">{engineLabel} Configuration</h3>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
Current: {modelName || 'Not configured'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedEngine === 'local_ai' && (
|
function ConfigEditor({ moderationType, config, onSaved }: {
|
||||||
<div>
|
moderationType: string;
|
||||||
<label className="text-xs font-medium text-gray-600 block mb-1">Model</label>
|
config?: ModerationConfig;
|
||||||
<select
|
onSaved: () => void;
|
||||||
value={modelId}
|
}) {
|
||||||
onChange={(e) => {
|
const [modelId, setModelId] = useState(config?.model_id || '');
|
||||||
const selected = LOCAL_MODELS.find(m => m.id === e.target.value);
|
const [modelName, setModelName] = useState(config?.model_name || '');
|
||||||
setModelId(e.target.value);
|
const [systemPrompt, setSystemPrompt] = useState(config?.system_prompt || '');
|
||||||
setModelName(selected?.name || '');
|
const [enabled, setEnabled] = useState(config?.enabled || false);
|
||||||
}}
|
const [saving, setSaving] = useState(false);
|
||||||
className="w-full text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
|
|
||||||
|
// Model picker
|
||||||
|
const [showPicker, setShowPicker] = useState(false);
|
||||||
|
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||||
|
const [modelsLoading, setModelsLoading] = useState(false);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [capability, setCapability] = useState('');
|
||||||
|
const searchTimer = useRef<any>(null);
|
||||||
|
|
||||||
|
// Test
|
||||||
|
const [testInput, setTestInput] = useState('');
|
||||||
|
const [testResult, setTestResult] = useState<any>(null);
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
|
||||||
|
const loadModels = useCallback((search?: string, cap?: string) => {
|
||||||
|
setModelsLoading(true);
|
||||||
|
api.listOpenRouterModels({ search, capability: cap })
|
||||||
|
.then((data) => setModels(data.models || []))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setModelsLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showPicker) loadModels(searchTerm || undefined, capability || undefined);
|
||||||
|
}, [showPicker]);
|
||||||
|
|
||||||
|
const onSearchChange = (val: string) => {
|
||||||
|
setSearchTerm(val);
|
||||||
|
if (searchTimer.current) clearTimeout(searchTimer.current);
|
||||||
|
searchTimer.current = setTimeout(() => {
|
||||||
|
loadModels(val || undefined, capability || undefined);
|
||||||
|
}, 400);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCapabilityChange = (val: string) => {
|
||||||
|
setCapability(val);
|
||||||
|
loadModels(searchTerm || undefined, val || undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectModel = (m: ModelInfo) => {
|
||||||
|
setModelId(m.id);
|
||||||
|
setModelName(m.name);
|
||||||
|
setShowPicker(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await api.setAIModerationConfig({
|
||||||
|
moderation_type: moderationType,
|
||||||
|
model_id: modelId,
|
||||||
|
model_name: modelName,
|
||||||
|
system_prompt: systemPrompt,
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
onSaved();
|
||||||
|
} catch (e: any) {
|
||||||
|
alert(e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTest = async () => {
|
||||||
|
if (!testInput.trim()) return;
|
||||||
|
setTesting(true);
|
||||||
|
setTestResult(null);
|
||||||
|
try {
|
||||||
|
const data = moderationType === 'text'
|
||||||
|
? { moderation_type: moderationType, content: testInput }
|
||||||
|
: { moderation_type: moderationType, image_url: testInput };
|
||||||
|
const res = await api.testAIModeration(data);
|
||||||
|
setTestResult(res.result);
|
||||||
|
} catch (e: any) {
|
||||||
|
setTestResult({ error: e.message });
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFree = (m: ModelInfo) => m.pricing.prompt === '0' || m.pricing.prompt === '0.0';
|
||||||
|
const isVision = (m: ModelInfo) => {
|
||||||
|
const modality = m.architecture?.modality;
|
||||||
|
return typeof modality === 'string' && modality.includes('image');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Model Selection */}
|
||||||
|
<div className="card p-5">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="font-semibold text-gray-900">
|
||||||
|
{MODERATION_TYPES.find(t => t.key === moderationType)?.label} Configuration
|
||||||
|
</h2>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<span className="text-sm text-gray-500">Enabled</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setEnabled(!enabled)}
|
||||||
|
className={`relative w-10 h-6 rounded-full transition-colors ${enabled ? 'bg-green-500' : 'bg-gray-300'}`}
|
||||||
>
|
>
|
||||||
<option value="">Select model...</option>
|
<span className={`absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${enabled ? 'left-[18px]' : 'left-0.5'}`} />
|
||||||
{LOCAL_MODELS.map(m => (
|
</button>
|
||||||
<option key={m.id} value={m.id}>{m.name}</option>
|
</label>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedEngine === 'openrouter' && (
|
{/* Selected Model */}
|
||||||
<div className="space-y-3">
|
<div className="mb-4">
|
||||||
<div>
|
<label className="text-sm font-medium text-gray-600 block mb-1">Model</label>
|
||||||
<label className="text-xs font-medium text-gray-600 block mb-1">Model</label>
|
<div className="flex gap-2">
|
||||||
<div
|
<div
|
||||||
onClick={() => setShowPicker(!showPicker)}
|
onClick={() => setShowPicker(!showPicker)}
|
||||||
className="flex items-center justify-between px-3 py-2 border border-gray-300 rounded-lg cursor-pointer hover:bg-gray-50"
|
className="flex-1 flex items-center justify-between px-3 py-2 border border-warm-300 rounded-lg cursor-pointer hover:bg-warm-50 transition-colors"
|
||||||
>
|
>
|
||||||
{modelId ? (
|
{modelId ? (
|
||||||
<span className="text-sm">{modelName || modelId}</span>
|
<div>
|
||||||
|
<span className="text-sm font-medium text-gray-900">{modelName || modelId}</span>
|
||||||
|
<span className="text-xs text-gray-400 ml-2 font-mono">{modelId}</span>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-gray-400">Select model...</span>
|
<span className="text-sm text-gray-400">Click to select a model...</span>
|
||||||
)}
|
)}
|
||||||
<ChevronDown className={`w-4 h-4 text-gray-400 transition-transform ${showPicker ? 'rotate-180' : ''}`} />
|
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Model Picker */}
|
||||||
{showPicker && (
|
{showPicker && (
|
||||||
<div className="mt-2 border border-gray-200 rounded-lg overflow-hidden">
|
<div className="mb-4 border border-warm-200 rounded-lg overflow-hidden">
|
||||||
<div className="p-2 bg-gray-50">
|
<div className="p-3 bg-warm-100 flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search models..."
|
placeholder="Search models..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => {
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
setSearchTerm(e.target.value);
|
className="w-full pl-9 pr-3 py-2 text-sm border border-warm-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||||
loadModels(e.target.value);
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-64 overflow-y-auto">
|
<select
|
||||||
|
value={capability}
|
||||||
|
onChange={(e) => onCapabilityChange(e.target.value)}
|
||||||
|
className="text-sm border border-warm-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||||
|
>
|
||||||
|
<option value="">All Models</option>
|
||||||
|
<option value="free">Free Only</option>
|
||||||
|
<option value="vision">Vision Only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-80 overflow-y-auto">
|
||||||
{modelsLoading ? (
|
{modelsLoading ? (
|
||||||
<div className="p-4 text-center text-sm text-gray-400">Loading...</div>
|
<div className="p-4 text-center text-gray-400 text-sm flex items-center justify-center gap-2">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" /> Loading models...
|
||||||
|
</div>
|
||||||
|
) : models.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-gray-400 text-sm">No models found</div>
|
||||||
) : (
|
) : (
|
||||||
models.map(m => (
|
models.map((m) => (
|
||||||
<div
|
<div
|
||||||
key={m.id}
|
key={m.id}
|
||||||
onClick={() => {
|
onClick={() => selectModel(m)}
|
||||||
setModelId(m.id);
|
className={`px-3 py-2.5 border-b border-warm-100 cursor-pointer hover:bg-brand-50 transition-colors ${modelId === m.id ? 'bg-brand-50' : ''}`}
|
||||||
setModelName(m.name);
|
|
||||||
setShowPicker(false);
|
|
||||||
}}
|
|
||||||
className="px-3 py-2 hover:bg-gray-50 cursor-pointer border-b border-gray-100"
|
|
||||||
>
|
>
|
||||||
<div className="text-sm font-medium">{m.name}</div>
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-xs text-gray-400">{m.id}</div>
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
{modelId === m.id && <Check className="w-4 h-4 text-brand-600 flex-shrink-0" />}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium text-gray-900 truncate">{m.name}</div>
|
||||||
|
<div className="text-xs text-gray-400 font-mono truncate">{m.id}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0 ml-2">
|
||||||
|
{isVision(m) && (
|
||||||
|
<span className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded font-medium">Vision</span>
|
||||||
|
)}
|
||||||
|
{isFree(m) ? (
|
||||||
|
<span className="text-xs bg-green-100 text-green-700 px-1.5 py-0.5 rounded font-medium">Free</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400">${m.pricing.prompt}/tok</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-gray-300">{(m.context_length / 1000).toFixed(0)}k ctx</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedEngine === 'openai' && (
|
{/* System Prompt */}
|
||||||
<p className="text-xs text-gray-500">OpenAI moderation is automatically configured. No additional settings needed.</p>
|
<div className="mb-4">
|
||||||
)}
|
<label className="text-sm font-medium text-gray-600 block mb-1">
|
||||||
|
System Prompt <span className="text-gray-400 font-normal">(leave blank for default)</span>
|
||||||
{selectedEngine === 'google' && (
|
|
||||||
<p className="text-xs text-gray-500">Google Vision SafeSearch is configured via service account. No additional settings needed.</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedEngine === 'azure' && (
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-medium text-gray-600 block mb-1">Deployment Name</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={modelId}
|
|
||||||
onChange={(e) => {
|
|
||||||
setModelId(e.target.value);
|
|
||||||
setModelName(e.target.value);
|
|
||||||
}}
|
|
||||||
placeholder="e.g., gpt-4o-vision"
|
|
||||||
className="w-full text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 mt-2">
|
|
||||||
Azure OpenAI deployment name (configured in Azure portal). Uses your Azure credits.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Save Button */}
|
|
||||||
<div className="card p-4 flex items-center justify-between">
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={enabled}
|
|
||||||
onChange={(e) => setEnabled(e.target.checked)}
|
|
||||||
className="w-4 h-4 text-brand-600 rounded focus:ring-2 focus:ring-brand-500"
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium text-gray-700">Enable {typeLabel}</span>
|
|
||||||
</label>
|
</label>
|
||||||
<button
|
<textarea
|
||||||
onClick={handleSave}
|
rows={5}
|
||||||
disabled={saving}
|
value={systemPrompt}
|
||||||
className="btn-primary text-sm disabled:opacity-40"
|
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||||
>
|
placeholder="Custom system prompt for this moderation type... Leave blank to use the built-in default."
|
||||||
{saving ? 'Saving...' : 'Save Configuration'}
|
className="w-full text-sm border border-warm-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500 font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
{config?.updated_at && `Last updated: ${new Date(config.updated_at).toLocaleString()}`}
|
||||||
|
</div>
|
||||||
|
<button onClick={handleSave} disabled={saving} className="btn-primary text-sm flex items-center gap-1.5">
|
||||||
|
<Sparkles className="w-4 h-4" /> {saving ? 'Saving...' : 'Save Configuration'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Test Terminal */}
|
{/* Test Panel */}
|
||||||
<div className="space-y-4">
|
<div className="card p-5">
|
||||||
{/* Test Input */}
|
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||||||
<div className="card p-4">
|
|
||||||
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
|
|
||||||
<Play className="w-4 h-4" /> Test Moderation
|
<Play className="w-4 h-4" /> Test Moderation
|
||||||
</h3>
|
</h3>
|
||||||
|
<p className="text-xs text-gray-400 mb-3">
|
||||||
{(selectedType.includes('image') || selectedType === 'video') ? (
|
{moderationType === 'text'
|
||||||
<div className="space-y-3">
|
? 'Enter text content to test moderation'
|
||||||
{/* File Upload */}
|
: 'Enter an image URL to test vision moderation'}
|
||||||
<div className="flex gap-2">
|
</p>
|
||||||
<label className="flex-1">
|
<div className="flex gap-2 mb-3">
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) {
|
|
||||||
handleFileUpload(file);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
<div className="w-full text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500 cursor-pointer hover:bg-gray-50 flex items-center justify-center gap-2">
|
|
||||||
<Upload className="w-4 h-4" />
|
|
||||||
{uploading ? 'Uploading...' : (uploadedFile ? `Uploaded: ${uploadedFile.name}` : 'Click to upload image...')}
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* URL Input */}
|
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
|
||||||
<span>OR</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={testInput}
|
value={testInput}
|
||||||
onChange={(e) => setTestInput(e.target.value)}
|
onChange={(e) => setTestInput(e.target.value)}
|
||||||
placeholder="Image URL..."
|
placeholder={moderationType === 'text' ? 'Enter test text...' : 'Enter image URL...'}
|
||||||
className="flex-1 text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
|
className="flex-1 text-sm border border-warm-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleTest()}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={testInput}
|
|
||||||
onChange={(e) => setTestInput(e.target.value)}
|
|
||||||
placeholder="Test text..."
|
|
||||||
className="flex-1 text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleTest()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-2 mt-3">
|
|
||||||
<button
|
<button
|
||||||
onClick={handleTest}
|
onClick={handleTest}
|
||||||
disabled={testing || (!testInput.trim() && !uploadedFile)}
|
disabled={testing || !modelId || !enabled}
|
||||||
className="btn-primary text-sm flex items-center gap-1.5 disabled:opacity-40"
|
className="btn-primary text-sm flex items-center gap-1.5 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{testing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
{testing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||||
Test
|
Test
|
||||||
</button>
|
</button>
|
||||||
{uploadedFile && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setUploadedFile(null);
|
|
||||||
setTestInput('');
|
|
||||||
}}
|
|
||||||
className="text-sm text-gray-500 hover:text-gray-700"
|
|
||||||
>
|
|
||||||
Clear Upload
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{!modelId && <p className="text-xs text-amber-600">Select and save a model first to test</p>}
|
||||||
|
|
||||||
{/* Terminal Output */}
|
{testResult && (
|
||||||
<div className="card p-0 overflow-hidden">
|
<div className={`p-4 rounded-lg text-sm ${testResult.error ? 'bg-red-50 text-red-700' : testResult.action === 'flag' ? 'bg-red-50' : testResult.action === 'nsfw' ? 'bg-amber-50' : 'bg-green-50'}`}>
|
||||||
<div className="bg-gray-900 px-4 py-2 flex items-center gap-2 border-b border-gray-700">
|
{testResult.error ? (
|
||||||
<Terminal className="w-4 h-4 text-green-400" />
|
<p>{testResult.error}</p>
|
||||||
<span className="text-xs font-mono text-green-400">moderation-test</span>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-950 p-4 font-mono text-xs text-green-400 h-[500px] overflow-y-auto">
|
|
||||||
{testHistory.length === 0 ? (
|
|
||||||
<div className="text-gray-600">Waiting for test input...</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
{testHistory.map((entry, idx) => (
|
{/* Verdict */}
|
||||||
<div key={idx} className="border-b border-gray-800 pb-3 last:border-0">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<div className="text-gray-500 mb-1">
|
<span className={`text-lg font-bold ${testResult.action === 'flag' ? 'text-red-700' : testResult.action === 'nsfw' ? 'text-amber-700' : 'text-green-700'}`}>
|
||||||
[{new Date(entry.timestamp).toLocaleTimeString()}] {entry.engine} • {entry.moderation_type} • {entry.duration}ms
|
{testResult.action === 'flag' ? '⛔ FLAGGED' : testResult.action === 'nsfw' ? '⚠️ NSFW' : '✅ CLEAN'}
|
||||||
|
</span>
|
||||||
|
{testResult.nsfw_reason && (
|
||||||
|
<span className="text-xs font-medium bg-amber-200 text-amber-800 px-2 py-0.5 rounded-full">{testResult.nsfw_reason}</span>
|
||||||
|
)}
|
||||||
|
{testResult.reason && <span className="text-gray-600">— {testResult.reason}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{entry.error ? (
|
{/* Overall Explanation */}
|
||||||
<div className="text-red-400">ERROR: {entry.error}</div>
|
{testResult.explanation && (
|
||||||
) : entry.result ? (
|
<div className="bg-white/60 rounded-lg p-3 border border-warm-200">
|
||||||
<div className="space-y-1">
|
<p className="text-xs font-semibold text-gray-500 uppercase mb-1">AI Analysis</p>
|
||||||
<div className={entry.result.flagged ? 'text-red-400' : 'text-green-400'}>
|
<p className="text-sm text-gray-700 leading-relaxed">{testResult.explanation}</p>
|
||||||
{entry.result.flagged ? '⛔ FLAGGED' : '✅ CLEAN'}
|
|
||||||
{entry.result.reason && `: ${entry.result.reason}`}
|
|
||||||
</div>
|
|
||||||
{entry.result.explanation && (
|
|
||||||
<div className="text-gray-400 pl-4">{entry.result.explanation}</div>
|
|
||||||
)}
|
|
||||||
{entry.result.hate !== undefined && (
|
|
||||||
<div className="text-gray-400 pl-4">
|
|
||||||
Hate: {(entry.result.hate * 100).toFixed(1)}% |
|
|
||||||
Greed: {(entry.result.greed * 100).toFixed(1)}% |
|
|
||||||
Delusion: {(entry.result.delusion * 100).toFixed(1)}%
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{entry.result.categories && entry.result.categories.length > 0 && (
|
|
||||||
<div className="text-yellow-400 pl-4">
|
{/* Score Bars with Detail */}
|
||||||
Categories: {entry.result.categories.join(', ')}
|
<div className="space-y-2">
|
||||||
|
<ScoreBarDetailed label="Hate" value={testResult.hate} detail={testResult.hate_detail} />
|
||||||
|
<ScoreBarDetailed label="Greed" value={testResult.greed} detail={testResult.greed_detail} />
|
||||||
|
<ScoreBarDetailed label="Delusion" value={testResult.delusion} detail={testResult.delusion_detail} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{testResult.raw_content && (
|
||||||
|
<details className="mt-2">
|
||||||
|
<summary className="text-xs text-gray-400 cursor-pointer">Raw model response</summary>
|
||||||
|
<pre className="mt-1 text-xs bg-white p-2 rounded border border-warm-200 overflow-x-auto whitespace-pre-wrap">{testResult.raw_content}</pre>
|
||||||
|
</details>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
</div>
|
}
|
||||||
</AdminShell>
|
|
||||||
|
function ScoreBarDetailed({ label, value, detail }: { label: string; value: number; detail?: string }) {
|
||||||
|
const pct = Math.round((value || 0) * 100);
|
||||||
|
const color = pct > 50 ? 'bg-red-500' : pct > 25 ? 'bg-amber-400' : 'bg-green-400';
|
||||||
|
const textColor = pct > 50 ? 'text-red-700' : pct > 25 ? 'text-amber-700' : 'text-green-700';
|
||||||
|
return (
|
||||||
|
<div className="bg-white/50 rounded-lg p-2.5 border border-warm-100">
|
||||||
|
<div className="flex justify-between text-xs mb-1">
|
||||||
|
<span className="font-semibold text-gray-700">{label}</span>
|
||||||
|
<span className={`font-mono font-bold ${textColor}`}>{pct}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 bg-gray-200 rounded-full overflow-hidden mb-1.5">
|
||||||
|
<div className={`h-full ${color} rounded-full transition-all`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
{detail && <p className="text-xs text-gray-500 leading-relaxed">{detail}</p>}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,57 +1,89 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef, useCallback } from 'react';
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuth } from '@/lib/auth';
|
import { useAuth } from '@/lib/auth';
|
||||||
import Altcha from '@/components/Altcha';
|
import Script from 'next/script';
|
||||||
|
|
||||||
|
const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || '';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [altchaToken, setAltchaToken] = useState('');
|
const [turnstileToken, setTurnstileToken] = useState('');
|
||||||
const [altchaVerified, setAltchaVerified] = useState(false);
|
const [turnstileReady, setTurnstileReady] = useState(false);
|
||||||
const emailRef = useRef('');
|
const turnstileRef = useRef<HTMLDivElement>(null);
|
||||||
const passwordRef = useRef('');
|
const widgetIdRef = useRef<string | null>(null);
|
||||||
|
const tokenRef = useRef('');
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const handleAltchaVerified = useCallback((payload: string) => {
|
// Keep ref in sync with state so the submit handler always has the latest value
|
||||||
setAltchaToken(payload);
|
useEffect(() => { tokenRef.current = turnstileToken; }, [turnstileToken]);
|
||||||
setAltchaVerified(true);
|
|
||||||
|
const renderTurnstile = useCallback(() => {
|
||||||
|
if (!TURNSTILE_SITE_KEY || !turnstileRef.current || !(window as any).turnstile) return;
|
||||||
|
if (widgetIdRef.current) {
|
||||||
|
try { (window as any).turnstile.remove(widgetIdRef.current); } catch {}
|
||||||
|
}
|
||||||
|
widgetIdRef.current = (window as any).turnstile.render(turnstileRef.current, {
|
||||||
|
sitekey: TURNSTILE_SITE_KEY,
|
||||||
|
size: 'normal',
|
||||||
|
theme: 'light',
|
||||||
|
callback: (token: string) => { setTurnstileToken(token); tokenRef.current = token; setTurnstileReady(true); },
|
||||||
|
'error-callback': () => { setTurnstileToken(''); tokenRef.current = ''; setTurnstileReady(false); },
|
||||||
|
'expired-callback': () => { setTurnstileToken(''); tokenRef.current = ''; setTurnstileReady(false); },
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleAltchaError = useCallback(() => {
|
useEffect(() => {
|
||||||
setAltchaToken('');
|
if ((window as any).turnstile && TURNSTILE_SITE_KEY) {
|
||||||
setAltchaVerified(false);
|
renderTurnstile();
|
||||||
}, []);
|
}
|
||||||
|
}, [renderTurnstile]);
|
||||||
|
|
||||||
const performLogin = useCallback(async () => {
|
const refreshTurnstile = () => {
|
||||||
if (!altchaToken) {
|
setTurnstileToken('');
|
||||||
setError('Please complete the security verification');
|
tokenRef.current = '';
|
||||||
|
setTurnstileReady(false);
|
||||||
|
setError('');
|
||||||
|
if (widgetIdRef.current && (window as any).turnstile) {
|
||||||
|
(window as any).turnstile.reset(widgetIdRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
if (TURNSTILE_SITE_KEY && !tokenRef.current) {
|
||||||
|
setError('Please complete the security check first.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await login(emailRef.current, passwordRef.current, altchaToken);
|
await login(email, password, tokenRef.current);
|
||||||
router.push('/');
|
router.push('/');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Login failed. Check your credentials.');
|
setError(err.message || 'Login failed. Check your credentials.');
|
||||||
|
// Reset turnstile for retry
|
||||||
|
refreshTurnstile();
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [login, router, altchaToken]);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setError('');
|
|
||||||
await performLogin();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-warm-100">
|
<div className="min-h-screen flex items-center justify-center bg-warm-100">
|
||||||
|
{TURNSTILE_SITE_KEY && (
|
||||||
|
<Script
|
||||||
|
src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
|
||||||
|
onReady={renderTurnstile}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
<div className="card p-8">
|
<div className="card p-8">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
|
|
@ -77,11 +109,7 @@ export default function LoginPage() {
|
||||||
type="email"
|
type="email"
|
||||||
className="input"
|
className="input"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => {
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
const v = e.target.value;
|
|
||||||
emailRef.current = v;
|
|
||||||
setEmail(v);
|
|
||||||
}}
|
|
||||||
placeholder="admin@sojorn.net"
|
placeholder="admin@sojorn.net"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
@ -92,26 +120,28 @@ export default function LoginPage() {
|
||||||
type="password"
|
type="password"
|
||||||
className="input"
|
className="input"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => {
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
const v = e.target.value;
|
|
||||||
passwordRef.current = v;
|
|
||||||
setPassword(v);
|
|
||||||
}}
|
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{/* Visible Turnstile widget */}
|
||||||
<Altcha
|
{TURNSTILE_SITE_KEY && (
|
||||||
challengeurl="https://api.sojorn.net/api/v1/admin/altcha-challenge"
|
<div className="flex flex-col items-center gap-2">
|
||||||
onVerified={handleAltchaVerified}
|
<div ref={turnstileRef} />
|
||||||
onError={handleAltchaError}
|
<button
|
||||||
/>
|
type="button"
|
||||||
|
onClick={refreshTurnstile}
|
||||||
|
className="text-xs text-gray-400 hover:text-gray-600 underline"
|
||||||
|
>
|
||||||
|
Refresh verification
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn-primary w-full"
|
className="btn-primary w-full"
|
||||||
disabled={loading || !altchaVerified}
|
disabled={loading || (!!TURNSTILE_SITE_KEY && !turnstileReady)}
|
||||||
>
|
>
|
||||||
{loading ? 'Signing in...' : 'Sign In'}
|
{loading ? 'Signing in...' : 'Sign In'}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,325 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import AdminShell from '@/components/AdminShell';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { formatDate, truncate } from '@/lib/utils';
|
|
||||||
import { ChevronLeft, ChevronRight, Search, Shield, ShieldOff, MessageSquare, Users, Building2, Pin, PinOff } from 'lucide-react';
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
type Neighborhood = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
city: string;
|
|
||||||
state: string;
|
|
||||||
zip_code: string;
|
|
||||||
group_name: string;
|
|
||||||
member_count: number;
|
|
||||||
admin_count: number;
|
|
||||||
board_post_count: number;
|
|
||||||
group_post_count: number;
|
|
||||||
created_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type NeighborhoodAdmin = {
|
|
||||||
user_id: string;
|
|
||||||
role: 'owner' | 'admin' | 'member';
|
|
||||||
handle: string;
|
|
||||||
display_name: string;
|
|
||||||
avatar_url: string;
|
|
||||||
joined_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function NeighborhoodsPage() {
|
|
||||||
const [items, setItems] = useState<Neighborhood[]>([]);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [zip, setZip] = useState('');
|
|
||||||
const [sort, setSort] = useState<'name' | 'zip' | 'members' | 'created'>('name');
|
|
||||||
const [order, setOrder] = useState<'asc' | 'desc'>('asc');
|
|
||||||
const [offset, setOffset] = useState(0);
|
|
||||||
const [selected, setSelected] = useState<Neighborhood | null>(null);
|
|
||||||
const [boardEntries, setBoardEntries] = useState<any[]>([]);
|
|
||||||
const [boardLoading, setBoardLoading] = useState(false);
|
|
||||||
const [boardSearch, setBoardSearch] = useState('');
|
|
||||||
const [adminUserId, setAdminUserId] = useState('');
|
|
||||||
const [admins, setAdmins] = useState<NeighborhoodAdmin[]>([]);
|
|
||||||
const [adminLoading, setAdminLoading] = useState(false);
|
|
||||||
const limit = 25;
|
|
||||||
|
|
||||||
const selectedStats = useMemo(() => {
|
|
||||||
if (!selected) return null;
|
|
||||||
return [
|
|
||||||
{ label: 'Members', value: selected.member_count, icon: <Users className="w-4 h-4" /> },
|
|
||||||
{ label: 'Group Admins', value: selected.admin_count, icon: <Shield className="w-4 h-4" /> },
|
|
||||||
{ label: 'Board Posts', value: selected.board_post_count, icon: <MessageSquare className="w-4 h-4" /> },
|
|
||||||
{ label: 'Group Posts', value: selected.group_post_count, icon: <Building2 className="w-4 h-4" /> },
|
|
||||||
];
|
|
||||||
}, [selected]);
|
|
||||||
|
|
||||||
const fetchNeighborhoods = () => {
|
|
||||||
setLoading(true);
|
|
||||||
api
|
|
||||||
.listNeighborhoods({ limit, offset, search: search || undefined, zip: zip || undefined, sort, order })
|
|
||||||
.then((data) => {
|
|
||||||
setItems(data.neighborhoods || []);
|
|
||||||
setTotal(data.total || 0);
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchBoardEntries = (id: string, searchTerm = '') => {
|
|
||||||
setBoardLoading(true);
|
|
||||||
api
|
|
||||||
.listNeighborhoodBoardEntries(id, { limit: 20, offset: 0, search: searchTerm || undefined })
|
|
||||||
.then((data) => setBoardEntries(data.entries || []))
|
|
||||||
.finally(() => setBoardLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchNeighborhoodAdmins = (id: string) => {
|
|
||||||
setAdminLoading(true);
|
|
||||||
api
|
|
||||||
.listNeighborhoodAdmins(id)
|
|
||||||
.then((data) => setAdmins(data.admins || []))
|
|
||||||
.finally(() => setAdminLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchNeighborhoods();
|
|
||||||
}, [offset, sort, order]);
|
|
||||||
|
|
||||||
const onSearch = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setOffset(0);
|
|
||||||
fetchNeighborhoods();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSelectNeighborhood = (n: Neighborhood) => {
|
|
||||||
setSelected(n);
|
|
||||||
setBoardSearch('');
|
|
||||||
fetchBoardEntries(n.id);
|
|
||||||
fetchNeighborhoodAdmins(n.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleBoardEntry = async (entryId: string, current: boolean) => {
|
|
||||||
if (!selected) return;
|
|
||||||
await api.updateNeighborhoodBoardEntry(selected.id, entryId, !current);
|
|
||||||
fetchBoardEntries(selected.id, boardSearch);
|
|
||||||
fetchNeighborhoods();
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateAdmin = async (action: 'assign' | 'remove') => {
|
|
||||||
if (!selected || !adminUserId.trim()) return;
|
|
||||||
await api.setNeighborhoodAdmin(selected.id, adminUserId.trim(), action);
|
|
||||||
setAdminUserId('');
|
|
||||||
fetchNeighborhoods();
|
|
||||||
fetchNeighborhoodAdmins(selected.id);
|
|
||||||
fetchBoardEntries(selected.id, boardSearch);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeAdminById = async (userId: string) => {
|
|
||||||
if (!selected || !userId) return;
|
|
||||||
await api.setNeighborhoodAdmin(selected.id, userId, 'remove');
|
|
||||||
fetchNeighborhoods();
|
|
||||||
fetchNeighborhoodAdmins(selected.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleBoardPin = async (entryId: string, current: boolean) => {
|
|
||||||
if (!selected) return;
|
|
||||||
await api.pinNeighborhoodBoardEntry(selected.id, entryId, !current);
|
|
||||||
fetchBoardEntries(selected.id, boardSearch);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminShell>
|
|
||||||
<div className="mb-6">
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Neighborhoods</h1>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">Search, organize, and moderate neighborhood communities by name and ZIP.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card p-4 mb-4 flex flex-wrap gap-3 items-center">
|
|
||||||
<form onSubmit={onSearch} className="flex-1 min-w-[220px] relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
||||||
<input
|
|
||||||
className="input pl-10"
|
|
||||||
placeholder="Search neighborhood, city, state, ZIP..."
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
<input
|
|
||||||
className="input w-32"
|
|
||||||
placeholder="ZIP"
|
|
||||||
value={zip}
|
|
||||||
onChange={(e) => setZip(e.target.value)}
|
|
||||||
/>
|
|
||||||
<select className="input w-auto" value={sort} onChange={(e) => setSort(e.target.value as any)}>
|
|
||||||
<option value="name">Sort: Name</option>
|
|
||||||
<option value="zip">Sort: ZIP</option>
|
|
||||||
<option value="members">Sort: Members</option>
|
|
||||||
<option value="created">Sort: Created</option>
|
|
||||||
</select>
|
|
||||||
<select className="input w-auto" value={order} onChange={(e) => setOrder(e.target.value as any)}>
|
|
||||||
<option value="asc">Asc</option>
|
|
||||||
<option value="desc">Desc</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-[1.4fr,1fr] gap-4">
|
|
||||||
<div className="card overflow-hidden">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead className="bg-warm-200">
|
|
||||||
<tr>
|
|
||||||
<th className="table-header">Neighborhood</th>
|
|
||||||
<th className="table-header">ZIP</th>
|
|
||||||
<th className="table-header">Members</th>
|
|
||||||
<th className="table-header">Admins</th>
|
|
||||||
<th className="table-header">Board</th>
|
|
||||||
<th className="table-header">Group</th>
|
|
||||||
<th className="table-header">Created</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-warm-300">
|
|
||||||
{loading ? (
|
|
||||||
[...Array(6)].map((_, i) => (
|
|
||||||
<tr key={i}>{[...Array(7)].map((__, j) => <td key={j} className="table-cell"><div className="h-4 bg-warm-300 rounded animate-pulse w-20" /></td>)}</tr>
|
|
||||||
))
|
|
||||||
) : items.length === 0 ? (
|
|
||||||
<tr><td colSpan={7} className="table-cell text-center text-gray-400 py-8">No neighborhoods found</td></tr>
|
|
||||||
) : (
|
|
||||||
items.map((n) => (
|
|
||||||
<tr key={n.id} className={`hover:bg-warm-50 cursor-pointer ${selected?.id === n.id ? 'bg-brand-50' : ''}`} onClick={() => onSelectNeighborhood(n)}>
|
|
||||||
<td className="table-cell">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-900">{n.name}</p>
|
|
||||||
<p className="text-xs text-gray-500">{truncate(`${n.city}, ${n.state}`, 30)}</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="table-cell">{n.zip_code || '—'}</td>
|
|
||||||
<td className="table-cell">{n.member_count}</td>
|
|
||||||
<td className="table-cell">{n.admin_count}</td>
|
|
||||||
<td className="table-cell">{n.board_post_count}</td>
|
|
||||||
<td className="table-cell">{n.group_post_count}</td>
|
|
||||||
<td className="table-cell text-gray-500 text-xs">{formatDate(n.created_at)}</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div className="border-t border-warm-300 px-4 py-3 flex items-center justify-between">
|
|
||||||
<p className="text-sm text-gray-500">Showing {Math.min(offset + 1, total)}–{Math.min(offset + limit, total)} of {total}</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button className="btn-secondary text-sm py-1.5 px-3" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - limit))}><ChevronLeft className="w-4 h-4" /></button>
|
|
||||||
<button className="btn-secondary text-sm py-1.5 px-3" disabled={offset + limit >= total} onClick={() => setOffset(offset + limit)}><ChevronRight className="w-4 h-4" /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card p-4 min-h-[300px]">
|
|
||||||
{!selected ? (
|
|
||||||
<p className="text-sm text-gray-500">Select a neighborhood to manage admins and board content.</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900">{selected.name}</h2>
|
|
||||||
<p className="text-sm text-gray-500">{selected.city}, {selected.state} {selected.zip_code ? `· ${selected.zip_code}` : ''}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{selectedStats?.map((stat) => (
|
|
||||||
<div key={stat.label} className="rounded-lg border border-warm-300 p-2 bg-warm-100">
|
|
||||||
<div className="flex items-center gap-2 text-gray-500 text-xs">{stat.icon}<span>{stat.label}</span></div>
|
|
||||||
<p className="text-lg font-semibold text-gray-900">{stat.value}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border border-warm-300 rounded-lg p-3 bg-warm-50">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-2">Neighborhood Admins</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input className="input" placeholder="User ID to assign/remove" value={adminUserId} onChange={(e) => setAdminUserId(e.target.value)} />
|
|
||||||
<button className="btn-primary text-sm" onClick={() => updateAdmin('assign')}><Shield className="w-4 h-4" />Assign</button>
|
|
||||||
<button className="btn-secondary text-sm" onClick={() => updateAdmin('remove')}><ShieldOff className="w-4 h-4" />Remove</button>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 rounded-lg border border-warm-300 bg-white overflow-hidden">
|
|
||||||
<div className="px-3 py-2 border-b border-warm-200 text-xs font-semibold text-gray-600">Current Moderators</div>
|
|
||||||
{adminLoading ? (
|
|
||||||
<p className="px-3 py-2 text-xs text-gray-500">Loading moderators…</p>
|
|
||||||
) : admins.length === 0 ? (
|
|
||||||
<p className="px-3 py-2 text-xs text-gray-500">No admins found for this neighborhood.</p>
|
|
||||||
) : (
|
|
||||||
admins.map((mod) => (
|
|
||||||
<div key={mod.user_id} className="px-3 py-2 border-b border-warm-100 last:border-0 flex items-center justify-between gap-2">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-800">{mod.display_name || mod.handle || mod.user_id}</p>
|
|
||||||
<p className="text-xs text-gray-500">@{mod.handle || 'unknown'} · {mod.role}</p>
|
|
||||||
</div>
|
|
||||||
{mod.role === 'admin' && (
|
|
||||||
<button className="text-xs text-red-600 hover:text-red-700 font-medium" onClick={() => removeAdminById(mod.user_id)}>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border border-warm-300 rounded-lg overflow-hidden">
|
|
||||||
<div className="p-3 bg-warm-100 border-b border-warm-300 flex items-center justify-between">
|
|
||||||
<p className="text-sm font-semibold text-gray-700">Board Moderation</p>
|
|
||||||
<form
|
|
||||||
className="relative"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (selected) fetchBoardEntries(selected.id, boardSearch);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
|
|
||||||
<input className="input h-8 pl-7 text-xs" placeholder="Search posts" value={boardSearch} onChange={(e) => setBoardSearch(e.target.value)} />
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-80 overflow-y-auto">
|
|
||||||
{boardLoading ? (
|
|
||||||
<p className="p-3 text-xs text-gray-500">Loading board entries…</p>
|
|
||||||
) : boardEntries.length === 0 ? (
|
|
||||||
<p className="p-3 text-xs text-gray-500">No board entries in this neighborhood.</p>
|
|
||||||
) : (
|
|
||||||
boardEntries.map((entry) => (
|
|
||||||
<div key={entry.id} className="p-3 border-b border-warm-200 last:border-0">
|
|
||||||
<p className="text-sm text-gray-900 line-clamp-2">{entry.body}</p>
|
|
||||||
<div className="mt-1 text-xs text-gray-500 flex items-center justify-between">
|
|
||||||
<span>@{entry.author?.handle || 'unknown'} · {entry.topic || 'community'}</span>
|
|
||||||
<span>{entry.upvotes || 0}↑ · {entry.reply_count || 0} replies</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
className={`text-xs font-medium ${entry.is_active ? 'text-red-600 hover:text-red-700' : 'text-green-600 hover:text-green-700'}`}
|
|
||||||
onClick={() => toggleBoardEntry(entry.id, !!entry.is_active)}
|
|
||||||
>
|
|
||||||
{entry.is_active ? 'Hide post' : 'Restore post'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`text-xs font-medium inline-flex items-center gap-1 ${entry.is_pinned ? 'text-orange-600 hover:text-orange-700' : 'text-brand-600 hover:text-brand-700'}`}
|
|
||||||
onClick={() => toggleBoardPin(entry.id, !!entry.is_pinned)}
|
|
||||||
>
|
|
||||||
{entry.is_pinned ? <PinOff className="w-3.5 h-3.5" /> : <Pin className="w-3.5 h-3.5" />}
|
|
||||||
{entry.is_pinned ? 'Unpin' : 'Pin'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AdminShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -8,106 +8,46 @@ import {
|
||||||
ChevronDown, ChevronUp, Bot, Clock, AlertCircle, CheckCircle, ExternalLink,
|
ChevronDown, ChevronUp, Bot, Clock, AlertCircle, CheckCircle, ExternalLink,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
// ─── Model Selector (OpenRouter / Local Ollama / OpenAI) ─────────
|
// ─── Model Selector (fetches from OpenRouter) ─────────
|
||||||
type ProviderTab = 'openrouter' | 'local' | 'openai';
|
|
||||||
|
|
||||||
const OPENAI_MODELS = [
|
|
||||||
{ id: 'gpt-4o', name: 'GPT-4o' },
|
|
||||||
{ id: 'gpt-4o-mini', name: 'GPT-4o Mini' },
|
|
||||||
{ id: 'gpt-4-turbo', name: 'GPT-4 Turbo' },
|
|
||||||
{ id: 'gpt-4', name: 'GPT-4' },
|
|
||||||
{ id: 'gpt-3.5-turbo', name: 'GPT-3.5 Turbo' },
|
|
||||||
{ id: 'o1', name: 'o1' },
|
|
||||||
{ id: 'o1-mini', name: 'o1 Mini' },
|
|
||||||
{ id: 'o3-mini', name: 'o3 Mini' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function detectProvider(modelId: string): ProviderTab {
|
|
||||||
if (modelId.startsWith('local/')) return 'local';
|
|
||||||
if (modelId.startsWith('openai/')) return 'openai';
|
|
||||||
return 'openrouter';
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripPrefix(modelId: string): string {
|
|
||||||
if (modelId.startsWith('local/')) return modelId.slice(6);
|
|
||||||
if (modelId.startsWith('openai/')) return modelId.slice(7);
|
|
||||||
return modelId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModelSelector({ value, onChange, className }: { value: string; onChange: (v: string) => void; className?: string }) {
|
function ModelSelector({ value, onChange, className }: { value: string; onChange: (v: string) => void; className?: string }) {
|
||||||
const [tab, setTab] = useState<ProviderTab>(detectProvider(value));
|
const [models, setModels] = useState<{ id: string; name: string }[]>([]);
|
||||||
const [orModels, setOrModels] = useState<{ id: string; name: string }[]>([]);
|
|
||||||
const [localModels, setLocalModels] = useState<{ id: string; name: string }[]>([]);
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
Promise.allSettled([
|
|
||||||
api.listOpenRouterModels().then((data) => {
|
api.listOpenRouterModels().then((data) => {
|
||||||
setOrModels((data.models || []).map((m: any) => ({ id: m.id, name: m.name || m.id })));
|
const list = (data.models || []).map((m: any) => ({ id: m.id, name: m.name || m.id }));
|
||||||
}),
|
setModels(list);
|
||||||
api.listLocalModels().then((data) => {
|
}).catch(() => {}).finally(() => setLoading(false));
|
||||||
setLocalModels((data.models || []).map((m: any) => ({ id: m.id, name: m.name || m.id })));
|
|
||||||
}),
|
|
||||||
]).finally(() => setLoading(false));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const currentModels = tab === 'openrouter' ? orModels : tab === 'local' ? localModels : OPENAI_MODELS;
|
|
||||||
const filtered = search
|
const filtered = search
|
||||||
? currentModels.filter((m) => m.id.toLowerCase().includes(search.toLowerCase()) || m.name.toLowerCase().includes(search.toLowerCase()))
|
? models.filter((m) => m.id.toLowerCase().includes(search.toLowerCase()) || m.name.toLowerCase().includes(search.toLowerCase()))
|
||||||
: currentModels;
|
: models;
|
||||||
|
|
||||||
const handleSelect = (rawId: string) => {
|
const displayName = models.find((m) => m.id === value)?.name || value;
|
||||||
const prefixed = tab === 'local' ? `local/${rawId}` : tab === 'openai' ? `openai/${rawId}` : rawId;
|
|
||||||
onChange(prefixed);
|
|
||||||
setOpen(false);
|
|
||||||
setSearch('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const rawValue = stripPrefix(value);
|
|
||||||
const allModels = [...orModels, ...localModels, ...OPENAI_MODELS];
|
|
||||||
const displayName = allModels.find((m) => m.id === rawValue)?.name || value;
|
|
||||||
|
|
||||||
const providerLabel = detectProvider(value) === 'local' ? 'Local' : detectProvider(value) === 'openai' ? 'OpenAI' : 'OR';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative ${className || ''}`}>
|
<div className={`relative ${className || ''}`}>
|
||||||
<button type="button" onClick={() => setOpen(!open)}
|
<button type="button" onClick={() => setOpen(!open)}
|
||||||
className="w-full px-3 py-2 border border-warm-300 rounded-lg text-sm text-left truncate bg-white hover:bg-warm-50 transition-colors flex items-center gap-1.5">
|
className="w-full px-3 py-2 border border-warm-300 rounded-lg text-sm text-left truncate bg-white hover:bg-warm-50 transition-colors">
|
||||||
<span className={`text-[9px] px-1.5 py-0.5 rounded font-bold flex-shrink-0 ${
|
{loading ? 'Loading models...' : displayName}
|
||||||
detectProvider(value) === 'local' ? 'bg-purple-100 text-purple-700' :
|
|
||||||
detectProvider(value) === 'openai' ? 'bg-green-100 text-green-700' :
|
|
||||||
'bg-blue-100 text-blue-700'
|
|
||||||
}`}>{providerLabel}</span>
|
|
||||||
<span className="truncate">{loading ? 'Loading...' : displayName}</span>
|
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute z-50 mt-1 w-full bg-white border border-warm-300 rounded-lg shadow-lg max-h-80 overflow-hidden flex flex-col min-w-[320px]">
|
<div className="absolute z-50 mt-1 w-full bg-white border border-warm-300 rounded-lg shadow-lg max-h-64 overflow-hidden flex flex-col">
|
||||||
{/* Provider tabs */}
|
<input type="text" placeholder="Search models..." value={search} onChange={(e) => setSearch(e.target.value)} autoFocus
|
||||||
<div className="flex border-b border-warm-200">
|
|
||||||
{([['openrouter', 'OpenRouter', orModels.length], ['local', 'Local / Ollama', localModels.length], ['openai', 'OpenAI', OPENAI_MODELS.length]] as const).map(([key, label, count]) => (
|
|
||||||
<button key={key} type="button"
|
|
||||||
onClick={() => { setTab(key as ProviderTab); setSearch(''); }}
|
|
||||||
className={`flex-1 px-2 py-2 text-xs font-medium transition-colors ${
|
|
||||||
tab === key ? 'text-brand-700 border-b-2 border-brand-500 bg-brand-50' : 'text-gray-500 hover:text-gray-700 hover:bg-warm-50'
|
|
||||||
}`}>
|
|
||||||
{label} <span className="text-[10px] text-gray-400">({count})</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<input type="text" placeholder={`Search ${tab} models...`} value={search} onChange={(e) => setSearch(e.target.value)} autoFocus
|
|
||||||
className="px-3 py-2 border-b border-warm-200 text-sm outline-none" />
|
className="px-3 py-2 border-b border-warm-200 text-sm outline-none" />
|
||||||
<div className="overflow-y-auto max-h-52">
|
<div className="overflow-y-auto max-h-52">
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<p className="p-3 text-xs text-gray-500">{loading ? 'Loading...' : tab === 'local' ? 'No local models (is Ollama running?)' : 'No models found'}</p>
|
<p className="p-3 text-xs text-gray-500">{loading ? 'Loading...' : 'No models found'}</p>
|
||||||
) : (
|
) : (
|
||||||
filtered.slice(0, 100).map((m) => (
|
filtered.slice(0, 100).map((m) => (
|
||||||
<button key={m.id} type="button"
|
<button key={m.id} type="button"
|
||||||
onClick={() => handleSelect(m.id)}
|
onClick={() => { onChange(m.id); setOpen(false); setSearch(''); }}
|
||||||
className={`w-full text-left px-3 py-1.5 text-xs hover:bg-brand-50 transition-colors ${
|
className={`w-full text-left px-3 py-1.5 text-xs hover:bg-brand-50 transition-colors ${
|
||||||
m.id === rawValue && tab === detectProvider(value) ? 'bg-brand-50 text-brand-700 font-medium' : 'text-gray-700'
|
m.id === value ? 'bg-brand-50 text-brand-700 font-medium' : 'text-gray-700'
|
||||||
}`}>
|
}`}>
|
||||||
<span className="block truncate font-medium">{m.name}</span>
|
<span className="block truncate font-medium">{m.name}</span>
|
||||||
<span className="block truncate text-[10px] text-gray-400 font-mono">{m.id}</span>
|
<span className="block truncate text-[10px] text-gray-400 font-mono">{m.id}</span>
|
||||||
|
|
@ -712,17 +652,6 @@ function EditAccountForm({ config, onDone }: { config: Config; onDone: () => voi
|
||||||
type PipelineTab = 'discovered' | 'posted' | 'failed' | 'skipped';
|
type PipelineTab = 'discovered' | 'posted' | 'failed' | 'skipped';
|
||||||
interface ArticleStats { discovered: number; posted: number; failed: number; skipped: number; total: number; }
|
interface ArticleStats { discovered: number; posted: number; failed: number; skipped: number; total: number; }
|
||||||
|
|
||||||
function groupByDate(articles: any[], dateField: string): Record<string, any[]> {
|
|
||||||
const groups: Record<string, any[]> = {};
|
|
||||||
for (const a of articles) {
|
|
||||||
const raw = a[dateField] || a.discovered_at;
|
|
||||||
const date = raw ? new Date(raw).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }) : 'Unknown';
|
|
||||||
if (!groups[date]) groups[date] = [];
|
|
||||||
groups[date].push(a);
|
|
||||||
}
|
|
||||||
return groups;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ArticlesPanel({ configId }: { configId: string }) {
|
function ArticlesPanel({ configId }: { configId: string }) {
|
||||||
const [articles, setArticles] = useState<any[]>([]);
|
const [articles, setArticles] = useState<any[]>([]);
|
||||||
const [stats, setStats] = useState<ArticleStats | null>(null);
|
const [stats, setStats] = useState<ArticleStats | null>(null);
|
||||||
|
|
@ -731,10 +660,6 @@ function ArticlesPanel({ configId }: { configId: string }) {
|
||||||
const [bulkCount, setBulkCount] = useState(5);
|
const [bulkCount, setBulkCount] = useState(5);
|
||||||
const [posting, setPosting] = useState(false);
|
const [posting, setPosting] = useState(false);
|
||||||
const [postResult, setPostResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [postResult, setPostResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({});
|
|
||||||
const [cleanupDate, setCleanupDate] = useState('');
|
|
||||||
const [cleanupAction, setCleanupAction] = useState<'skip' | 'delete'>('skip');
|
|
||||||
const [cleanupLoading, setCleanupLoading] = useState(false);
|
|
||||||
|
|
||||||
const fetchTab = async (t: PipelineTab) => {
|
const fetchTab = async (t: PipelineTab) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -744,7 +669,7 @@ function ArticlesPanel({ configId }: { configId: string }) {
|
||||||
setArticles(data.articles || []);
|
setArticles(data.articles || []);
|
||||||
if (data.stats) setStats(data.stats);
|
if (data.stats) setStats(data.stats);
|
||||||
} else {
|
} else {
|
||||||
const data = await api.getPostedArticles(configId, 100, t);
|
const data = await api.getPostedArticles(configId, 50, t);
|
||||||
setArticles(data.articles || []);
|
setArticles(data.articles || []);
|
||||||
if (data.stats) setStats(data.stats);
|
if (data.stats) setStats(data.stats);
|
||||||
}
|
}
|
||||||
|
|
@ -772,35 +697,6 @@ function ArticlesPanel({ configId }: { configId: string }) {
|
||||||
setPosting(false);
|
setPosting(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleArticleAction = async (articleId: string, action: 'post' | 'skip' | 'delete') => {
|
|
||||||
setActionLoading((p) => ({ ...p, [articleId]: true }));
|
|
||||||
try {
|
|
||||||
if (action === 'post') await api.postSpecificArticle(articleId);
|
|
||||||
else if (action === 'skip') await api.skipArticle(articleId);
|
|
||||||
else if (action === 'delete') await api.deleteArticle(articleId);
|
|
||||||
fetchTab(tab);
|
|
||||||
} catch (e: any) {
|
|
||||||
setPostResult({ ok: false, msg: `${action} failed: ${e.message}` });
|
|
||||||
}
|
|
||||||
setActionLoading((p) => ({ ...p, [articleId]: false }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCleanup = async () => {
|
|
||||||
if (!cleanupDate) return;
|
|
||||||
if (!confirm(`${cleanupAction === 'delete' ? 'Permanently delete' : 'Skip'} all pending articles before ${cleanupDate}?`)) return;
|
|
||||||
setCleanupLoading(true);
|
|
||||||
setPostResult(null);
|
|
||||||
try {
|
|
||||||
const resp = await api.cleanupPendingArticles(configId, cleanupDate, cleanupAction);
|
|
||||||
setPostResult({ ok: true, msg: resp.message });
|
|
||||||
if (resp.stats) setStats(resp.stats);
|
|
||||||
fetchTab(tab);
|
|
||||||
} catch (e: any) {
|
|
||||||
setPostResult({ ok: false, msg: e.message });
|
|
||||||
}
|
|
||||||
setCleanupLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const tabConfig: { key: PipelineTab; label: string; color: string; bgColor: string }[] = [
|
const tabConfig: { key: PipelineTab; label: string; color: string; bgColor: string }[] = [
|
||||||
{ key: 'discovered', label: 'Pending', color: 'text-blue-700', bgColor: 'bg-blue-100' },
|
{ key: 'discovered', label: 'Pending', color: 'text-blue-700', bgColor: 'bg-blue-100' },
|
||||||
{ key: 'posted', label: 'Posted', color: 'text-green-700', bgColor: 'bg-green-100' },
|
{ key: 'posted', label: 'Posted', color: 'text-green-700', bgColor: 'bg-green-100' },
|
||||||
|
|
@ -810,9 +706,6 @@ function ArticlesPanel({ configId }: { configId: string }) {
|
||||||
|
|
||||||
const getCount = (key: PipelineTab) => stats ? stats[key] : 0;
|
const getCount = (key: PipelineTab) => stats ? stats[key] : 0;
|
||||||
|
|
||||||
const dateField = tab === 'posted' ? 'posted_at' : tab === 'discovered' ? 'discovered_at' : 'discovered_at';
|
|
||||||
const grouped = groupByDate(articles, tab === 'discovered' ? 'pub_date' : dateField);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 border border-warm-200 rounded-lg overflow-hidden">
|
<div className="mt-3 border border-warm-200 rounded-lg overflow-hidden">
|
||||||
{/* Stats bar */}
|
{/* Stats bar */}
|
||||||
|
|
@ -840,15 +733,11 @@ function ArticlesPanel({ configId }: { configId: string }) {
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<button onClick={() => fetchTab(tab)} className="ml-auto px-2 py-1 text-gray-400 hover:text-gray-600">
|
|
||||||
<RefreshCw className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bulk post controls — only on discovered tab */}
|
{/* Bulk post controls — only on discovered tab */}
|
||||||
{tab === 'discovered' && stats && stats.discovered > 0 && (
|
{tab === 'discovered' && stats && stats.discovered > 0 && (
|
||||||
<div className="border-b border-warm-200">
|
<div className="flex items-center gap-2 px-3 py-2 bg-blue-50 border-b border-warm-200">
|
||||||
<div className="flex items-center gap-2 px-3 py-2 bg-blue-50">
|
|
||||||
<span className="text-[10px] font-medium text-gray-600">Post:</span>
|
<span className="text-[10px] font-medium text-gray-600">Post:</span>
|
||||||
<input type="number" min={1} max={stats.discovered} value={bulkCount}
|
<input type="number" min={1} max={stats.discovered} value={bulkCount}
|
||||||
onChange={(e) => setBulkCount(Math.max(1, Number(e.target.value)))}
|
onChange={(e) => setBulkCount(Math.max(1, Number(e.target.value)))}
|
||||||
|
|
@ -861,100 +750,42 @@ function ArticlesPanel({ configId }: { configId: string }) {
|
||||||
className="px-2.5 py-1 bg-green-600 text-white rounded text-xs font-medium hover:bg-green-700 disabled:opacity-50">
|
className="px-2.5 py-1 bg-green-600 text-white rounded text-xs font-medium hover:bg-green-700 disabled:opacity-50">
|
||||||
{posting ? 'Posting...' : `Post All (${stats.discovered})`}
|
{posting ? 'Posting...' : `Post All (${stats.discovered})`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 bg-amber-50">
|
|
||||||
<span className="text-[10px] font-medium text-gray-600">Cleanup before:</span>
|
|
||||||
<input type="date" value={cleanupDate} onChange={(e) => setCleanupDate(e.target.value)}
|
|
||||||
className="px-1.5 py-1 border border-warm-300 rounded text-xs" />
|
|
||||||
<select value={cleanupAction} onChange={(e) => setCleanupAction(e.target.value as 'skip' | 'delete')}
|
|
||||||
className="px-1.5 py-1 border border-warm-300 rounded text-xs">
|
|
||||||
<option value="skip">Skip</option>
|
|
||||||
<option value="delete">Delete</option>
|
|
||||||
</select>
|
|
||||||
<button onClick={handleCleanup} disabled={cleanupLoading || !cleanupDate}
|
|
||||||
className="px-2.5 py-1 bg-amber-600 text-white rounded text-xs font-medium hover:bg-amber-700 disabled:opacity-50">
|
|
||||||
{cleanupLoading ? 'Cleaning...' : 'Cleanup'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Result message */}
|
|
||||||
{postResult && (
|
{postResult && (
|
||||||
<div className={`flex items-center gap-2 px-3 py-1.5 text-xs border-b border-warm-200 ${postResult.ok ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'}`}>
|
<span className={`text-xs ml-auto ${postResult.ok ? 'text-green-600' : 'text-red-600'}`}>{postResult.msg}</span>
|
||||||
{postResult.ok ? <CheckCircle className="w-3 h-3" /> : <AlertCircle className="w-3 h-3" />}
|
)}
|
||||||
<span>{postResult.msg}</span>
|
|
||||||
<button onClick={() => setPostResult(null)} className="ml-auto text-gray-400 hover:text-gray-600 text-[10px]">✕</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Article list grouped by date */}
|
{/* Article list */}
|
||||||
<div className="max-h-80 overflow-y-auto">
|
<div className="max-h-56 overflow-y-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="p-3 text-xs text-gray-500">Loading...</p>
|
<p className="p-3 text-xs text-gray-500">Loading...</p>
|
||||||
) : articles.length === 0 ? (
|
) : articles.length === 0 ? (
|
||||||
<p className="p-3 text-xs text-gray-500">No {tab} articles</p>
|
<p className="p-3 text-xs text-gray-500">No {tab} articles</p>
|
||||||
) : (
|
) : tab === 'discovered' ? (
|
||||||
Object.entries(grouped).map(([date, items]) => (
|
articles.map((a, i) => (
|
||||||
<div key={date}>
|
<div key={i} className="p-2 border-b border-warm-100 last:border-0">
|
||||||
<div className="sticky top-0 px-3 py-1 bg-warm-100 border-b border-warm-200">
|
|
||||||
<span className="text-[10px] font-semibold text-gray-500 uppercase tracking-wide">{date}</span>
|
|
||||||
<span className="text-[10px] text-gray-400 ml-2">({items.length})</span>
|
|
||||||
</div>
|
|
||||||
{items.map((a: any) => {
|
|
||||||
const id = a.id || a.link;
|
|
||||||
const isActioning = actionLoading[id];
|
|
||||||
return (
|
|
||||||
<div key={id} className="px-3 py-1.5 border-b border-warm-100 last:border-0 hover:bg-warm-50 group">
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[10px] px-1.5 py-0.5 bg-warm-200 rounded text-gray-600 flex-shrink-0">
|
<span className="text-[10px] px-1.5 py-0.5 bg-warm-200 rounded text-gray-600 flex-shrink-0">{a.source}</span>
|
||||||
{a.source || a.source_name}
|
<a href={a.link} target="_blank" className="text-xs font-medium text-brand-600 hover:underline flex items-center gap-1 truncate">
|
||||||
</span>
|
{a.title} <ExternalLink className="w-3 h-3 flex-shrink-0" />
|
||||||
<a href={a.link} target="_blank" rel="noopener noreferrer"
|
|
||||||
className="text-xs font-medium text-brand-600 hover:underline flex items-center gap-1 truncate flex-1 min-w-0">
|
|
||||||
<span className="truncate">{a.title}</span>
|
|
||||||
<ExternalLink className="w-3 h-3 flex-shrink-0" />
|
|
||||||
</a>
|
</a>
|
||||||
{a.posted_at && (
|
|
||||||
<span className="text-[10px] text-gray-400 flex-shrink-0">
|
|
||||||
{new Date(a.posted_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{/* Per-article actions for pending tab */}
|
|
||||||
{tab === 'discovered' && a.id && (
|
|
||||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
|
|
||||||
<button onClick={() => handleArticleAction(a.id, 'post')} disabled={isActioning}
|
|
||||||
title="Post this article" className="p-1 rounded hover:bg-green-100 text-green-600 disabled:opacity-50">
|
|
||||||
<Play className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
<button onClick={() => handleArticleAction(a.id, 'skip')} disabled={isActioning}
|
|
||||||
title="Skip this article" className="p-1 rounded hover:bg-amber-100 text-amber-600 disabled:opacity-50">
|
|
||||||
<Clock className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
<button onClick={() => handleArticleAction(a.id, 'delete')} disabled={isActioning}
|
|
||||||
title="Delete this article" className="p-1 rounded hover:bg-red-100 text-red-500 disabled:opacity-50">
|
|
||||||
<Trash2 className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{a.description && <p className="text-[10px] text-gray-500 mt-0.5 line-clamp-2">{a.description}</p>}
|
||||||
{/* Delete action for other tabs */}
|
|
||||||
{tab !== 'discovered' && a.id && (
|
|
||||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
|
|
||||||
<button onClick={() => handleArticleAction(a.id, 'delete')} disabled={isActioning}
|
|
||||||
title="Delete this article" className="p-1 rounded hover:bg-red-100 text-red-500 disabled:opacity-50">
|
|
||||||
<Trash2 className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
))
|
||||||
|
) : (
|
||||||
|
articles.map((a) => (
|
||||||
|
<div key={a.id} className="p-2 border-b border-warm-100 last:border-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 bg-warm-200 rounded text-gray-600 flex-shrink-0">{a.source_name}</span>
|
||||||
|
<a href={a.link} target="_blank" className="text-xs font-medium text-brand-600 hover:underline flex items-center gap-1 truncate">
|
||||||
|
{a.title} <ExternalLink className="w-3 h-3 flex-shrink-0" />
|
||||||
|
</a>
|
||||||
|
{a.posted_at && <span className="text-[10px] text-gray-400 ml-auto flex-shrink-0">{new Date(a.posted_at).toLocaleString()}</span>}
|
||||||
</div>
|
</div>
|
||||||
{a.description && tab === 'discovered' && (
|
|
||||||
<p className="text-[10px] text-gray-500 mt-0.5 ml-[calc(1.5rem+0.5rem)] line-clamp-1">{a.description}</p>
|
|
||||||
)}
|
|
||||||
{a.error_message && <p className="text-[10px] text-red-500 mt-0.5">{a.error_message}</p>}
|
{a.error_message && <p className="text-[10px] text-red-500 mt-0.5">{a.error_message}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,700 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import AdminShell from '@/components/AdminShell';
|
|
||||||
import SelectionBar from '@/components/SelectionBar';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { statusColor, formatDateTime } from '@/lib/utils';
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
|
||||||
import {
|
|
||||||
Shield, CheckCircle, XCircle, Trash2, Ban, AlertTriangle, Scale, Flag,
|
|
||||||
RefreshCw, ChevronDown, ChevronUp, User, Clock, ExternalLink,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
type Tab = 'moderation' | 'appeals' | 'reports';
|
|
||||||
|
|
||||||
// ─── Score Bar ────────────────────────────────────────
|
|
||||||
function ScoreBar({ label, value }: { label: string; value: number }) {
|
|
||||||
const pct = Math.round(value * 100);
|
|
||||||
const color = pct > 70 ? 'bg-red-500' : pct > 40 ? 'bg-yellow-500' : 'bg-green-500';
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 text-xs">
|
|
||||||
<span className="w-16 text-gray-500">{label}</span>
|
|
||||||
<div className="flex-1 h-2 bg-warm-300 rounded-full overflow-hidden">
|
|
||||||
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
|
|
||||||
</div>
|
|
||||||
<span className="w-8 text-right font-mono text-gray-600">{pct}%</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── User Context Panel ───────────────────────────────
|
|
||||||
function UserContextPanel({ userId, handle }: { userId?: string; handle?: string }) {
|
|
||||||
const [user, setUser] = useState<any>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const fetchUser = useCallback(async () => {
|
|
||||||
if (!userId) return;
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const data = await api.getUser(userId);
|
|
||||||
setUser(data);
|
|
||||||
} catch {}
|
|
||||||
setLoading(false);
|
|
||||||
}, [userId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open && !user && userId) fetchUser();
|
|
||||||
}, [open, user, userId, fetchUser]);
|
|
||||||
|
|
||||||
if (!userId) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setOpen(!open)}
|
|
||||||
className="flex items-center gap-1.5 text-xs text-brand-600 hover:text-brand-800 transition-colors"
|
|
||||||
>
|
|
||||||
<User className="w-3 h-3" />
|
|
||||||
<span>@{handle || 'unknown'}</span>
|
|
||||||
{open ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
|
||||||
</button>
|
|
||||||
{open && (
|
|
||||||
<div className="mt-2 p-3 bg-warm-50 border border-warm-200 rounded-lg text-xs space-y-1.5">
|
|
||||||
{loading ? (
|
|
||||||
<p className="text-gray-400">Loading user context...</p>
|
|
||||||
) : user ? (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium text-gray-700">{user.display_name || user.handle}</span>
|
|
||||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
|
|
||||||
user.status === 'active' ? 'bg-green-100 text-green-700' :
|
|
||||||
user.status === 'suspended' ? 'bg-red-100 text-red-700' :
|
|
||||||
'bg-yellow-100 text-yellow-700'
|
|
||||||
}`}>{user.status}</span>
|
|
||||||
</div>
|
|
||||||
{user.violation_count != null && (
|
|
||||||
<p className="text-gray-500">
|
|
||||||
Violations: <span className={`font-medium ${user.violation_count > 0 ? 'text-red-600' : 'text-green-600'}`}>{user.violation_count}</span>
|
|
||||||
{user.warning_count > 0 && <span className="ml-2">Warnings: <span className="font-medium text-yellow-600">{user.warning_count}</span></span>}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{user.created_at && (
|
|
||||||
<p className="text-gray-400">Joined {formatDateTime(user.created_at)}</p>
|
|
||||||
)}
|
|
||||||
<Link href={`/users/${userId}`} className="inline-flex items-center gap-1 text-brand-500 hover:text-brand-700">
|
|
||||||
View full profile <ExternalLink className="w-3 h-3" />
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-gray-400">User not found</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Tab Badge ────────────────────────────────────────
|
|
||||||
function TabBadge({ count }: { count: number }) {
|
|
||||||
if (count === 0) return null;
|
|
||||||
return (
|
|
||||||
<span className="ml-1.5 px-1.5 py-0.5 text-[10px] font-bold rounded-full bg-red-500 text-white min-w-[18px] text-center">
|
|
||||||
{count > 99 ? '99+' : count}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main Page ────────────────────────────────────────
|
|
||||||
export default function SafetyWorkspacePage() {
|
|
||||||
const [tab, setTab] = useState<Tab>('moderation');
|
|
||||||
|
|
||||||
// Moderation state
|
|
||||||
const [modItems, setModItems] = useState<any[]>([]);
|
|
||||||
const [modTotal, setModTotal] = useState(0);
|
|
||||||
const [modLoading, setModLoading] = useState(true);
|
|
||||||
const [modStatus, setModStatus] = useState('pending');
|
|
||||||
const [modSelected, setModSelected] = useState<Set<string>>(new Set());
|
|
||||||
const [modBulkLoading, setModBulkLoading] = useState(false);
|
|
||||||
const [reviewingId, setReviewingId] = useState<string | null>(null);
|
|
||||||
const [banReason, setBanReason] = useState('');
|
|
||||||
const [customBanReason, setCustomBanReason] = useState(false);
|
|
||||||
|
|
||||||
// Appeals state
|
|
||||||
const [appeals, setAppeals] = useState<any[]>([]);
|
|
||||||
const [appealsTotal, setAppealsTotal] = useState(0);
|
|
||||||
const [appealsLoading, setAppealsLoading] = useState(true);
|
|
||||||
const [appealsStatus, setAppealsStatus] = useState('pending');
|
|
||||||
const [appealReviewId, setAppealReviewId] = useState<string | null>(null);
|
|
||||||
const [appealDecision, setAppealDecision] = useState('');
|
|
||||||
const [restoreContent, setRestoreContent] = useState(false);
|
|
||||||
|
|
||||||
// Reports state
|
|
||||||
const [reports, setReports] = useState<any[]>([]);
|
|
||||||
const [reportsTotal, setReportsTotal] = useState(0);
|
|
||||||
const [reportsLoading, setReportsLoading] = useState(true);
|
|
||||||
const [reportsStatus, setReportsStatus] = useState('pending');
|
|
||||||
const [reportsSelected, setReportsSelected] = useState<Set<string>>(new Set());
|
|
||||||
const [reportsBulkLoading, setReportsBulkLoading] = useState(false);
|
|
||||||
|
|
||||||
// Pending counts for badges
|
|
||||||
const [pendingCounts, setPendingCounts] = useState({ moderation: 0, appeals: 0, reports: 0 });
|
|
||||||
|
|
||||||
const banReasons = [
|
|
||||||
'Hate speech or slurs',
|
|
||||||
'Harassment or bullying',
|
|
||||||
'Spam or scam activity',
|
|
||||||
'Posting illegal content',
|
|
||||||
'Repeated violations after warnings',
|
|
||||||
'Ban evasion (alt account)',
|
|
||||||
];
|
|
||||||
|
|
||||||
// ── Fetch functions ──
|
|
||||||
const fetchModeration = useCallback(() => {
|
|
||||||
setModLoading(true);
|
|
||||||
api.getModerationQueue({ limit: 50, status: modStatus })
|
|
||||||
.then((data) => { setModItems(data.items || []); setModTotal(data.total || 0); })
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setModLoading(false));
|
|
||||||
}, [modStatus]);
|
|
||||||
|
|
||||||
const fetchAppeals = useCallback(() => {
|
|
||||||
setAppealsLoading(true);
|
|
||||||
api.listAppeals({ limit: 50, status: appealsStatus })
|
|
||||||
.then((data) => { setAppeals(data.appeals || []); setAppealsTotal(data.total || 0); })
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setAppealsLoading(false));
|
|
||||||
}, [appealsStatus]);
|
|
||||||
|
|
||||||
const fetchReports = useCallback(() => {
|
|
||||||
setReportsLoading(true);
|
|
||||||
api.listReports({ limit: 50, status: reportsStatus })
|
|
||||||
.then((data) => { setReports(data.reports || []); setReportsTotal(data.total || 0); })
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setReportsLoading(false));
|
|
||||||
}, [reportsStatus]);
|
|
||||||
|
|
||||||
const fetchPendingCounts = useCallback(() => {
|
|
||||||
Promise.allSettled([
|
|
||||||
api.getModerationQueue({ limit: 1, status: 'pending' }),
|
|
||||||
api.listAppeals({ limit: 1, status: 'pending' }),
|
|
||||||
api.listReports({ limit: 1, status: 'pending' }),
|
|
||||||
]).then(([mod, app, rep]) => {
|
|
||||||
setPendingCounts({
|
|
||||||
moderation: mod.status === 'fulfilled' ? (mod.value.total || 0) : 0,
|
|
||||||
appeals: app.status === 'fulfilled' ? (app.value.total || 0) : 0,
|
|
||||||
reports: rep.status === 'fulfilled' ? (rep.value.total || 0) : 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => { fetchPendingCounts(); }, [fetchPendingCounts]);
|
|
||||||
useEffect(() => { fetchModeration(); }, [fetchModeration]);
|
|
||||||
useEffect(() => { fetchAppeals(); }, [fetchAppeals]);
|
|
||||||
useEffect(() => { fetchReports(); }, [fetchReports]);
|
|
||||||
|
|
||||||
const refreshAll = () => {
|
|
||||||
fetchModeration();
|
|
||||||
fetchAppeals();
|
|
||||||
fetchReports();
|
|
||||||
fetchPendingCounts();
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Moderation actions ──
|
|
||||||
const handleModReview = async (id: string, action: string) => {
|
|
||||||
try {
|
|
||||||
await api.reviewModerationFlag(id, action, banReason || 'Admin review');
|
|
||||||
setReviewingId(null);
|
|
||||||
setBanReason('');
|
|
||||||
fetchModeration();
|
|
||||||
fetchPendingCounts();
|
|
||||||
} catch (e: any) {
|
|
||||||
alert(`Action failed: ${e.message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleModBulk = async (action: string) => {
|
|
||||||
setModBulkLoading(true);
|
|
||||||
try {
|
|
||||||
await api.bulkReviewModeration(Array.from(modSelected), action, 'Bulk admin review');
|
|
||||||
setModSelected(new Set());
|
|
||||||
fetchModeration();
|
|
||||||
fetchPendingCounts();
|
|
||||||
} catch (e: any) {
|
|
||||||
alert(`Bulk action failed: ${e.message}`);
|
|
||||||
}
|
|
||||||
setModBulkLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Appeal actions ──
|
|
||||||
const handleAppealReview = async (id: string, decision: 'approved' | 'rejected') => {
|
|
||||||
if (!appealDecision.trim()) return;
|
|
||||||
try {
|
|
||||||
await api.reviewAppeal(id, decision, appealDecision, restoreContent);
|
|
||||||
setAppealReviewId(null);
|
|
||||||
setAppealDecision('');
|
|
||||||
setRestoreContent(false);
|
|
||||||
fetchAppeals();
|
|
||||||
fetchPendingCounts();
|
|
||||||
} catch {}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Report actions ──
|
|
||||||
const handleReportUpdate = async (id: string, status: string) => {
|
|
||||||
try {
|
|
||||||
await api.updateReportStatus(id, status);
|
|
||||||
fetchReports();
|
|
||||||
fetchPendingCounts();
|
|
||||||
} catch {}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReportsBulk = async (action: string) => {
|
|
||||||
setReportsBulkLoading(true);
|
|
||||||
try {
|
|
||||||
await api.bulkUpdateReports(Array.from(reportsSelected), action);
|
|
||||||
setReportsSelected(new Set());
|
|
||||||
fetchReports();
|
|
||||||
fetchPendingCounts();
|
|
||||||
} catch {}
|
|
||||||
setReportsBulkLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const totalPending = pendingCounts.moderation + pendingCounts.appeals + pendingCounts.reports;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminShell>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-6 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
|
||||||
<Shield className="w-6 h-6 text-brand-500" />
|
|
||||||
Safety Workspace
|
|
||||||
{totalPending > 0 && (
|
|
||||||
<span className="ml-2 px-2 py-0.5 text-xs font-bold rounded-full bg-red-500 text-white">
|
|
||||||
{totalPending} pending
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">Unified moderation, appeals, and reports management</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={refreshAll} className="flex items-center gap-2 px-3 py-2 text-sm border border-warm-300 rounded-lg hover:bg-warm-200 transition-colors">
|
|
||||||
<RefreshCw className="w-4 h-4" /> Refresh All
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="flex border-b border-warm-300 mb-6">
|
|
||||||
{([
|
|
||||||
['moderation', 'Moderation Queue', Shield, pendingCounts.moderation],
|
|
||||||
['appeals', 'Appeals', Scale, pendingCounts.appeals],
|
|
||||||
['reports', 'Reports', Flag, pendingCounts.reports],
|
|
||||||
] as const).map(([key, label, Icon, count]) => (
|
|
||||||
<button
|
|
||||||
key={key}
|
|
||||||
onClick={() => setTab(key as Tab)}
|
|
||||||
className={`flex items-center gap-2 px-5 py-3 text-sm font-medium border-b-2 transition-colors ${
|
|
||||||
tab === key
|
|
||||||
? 'border-brand-500 text-brand-700 bg-brand-50/50'
|
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-warm-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon className="w-4 h-4" />
|
|
||||||
{label}
|
|
||||||
<TabBadge count={count as number} />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ═══ MODERATION TAB ═══ */}
|
|
||||||
{tab === 'moderation' && (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<p className="text-sm text-gray-500">{modTotal} items {modStatus}</p>
|
|
||||||
<select className="input w-auto text-sm" value={modStatus} onChange={(e) => { setModStatus(e.target.value); setModSelected(new Set()); }}>
|
|
||||||
<option value="pending">Pending</option>
|
|
||||||
<option value="actioned">Actioned</option>
|
|
||||||
<option value="dismissed">Dismissed</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{modStatus === 'pending' && (
|
|
||||||
<SelectionBar
|
|
||||||
count={modSelected.size}
|
|
||||||
total={modItems.length}
|
|
||||||
onSelectAll={() => setModSelected(new Set(modItems.map((i) => i.id)))}
|
|
||||||
onClearSelection={() => setModSelected(new Set())}
|
|
||||||
loading={modBulkLoading}
|
|
||||||
actions={[
|
|
||||||
{ label: 'Approve All', action: 'approve', color: 'bg-green-50 text-green-700 hover:bg-green-100', icon: <CheckCircle className="w-3.5 h-3.5" /> },
|
|
||||||
{ label: 'Dismiss All', action: 'dismiss', color: 'bg-gray-100 text-gray-700 hover:bg-gray-200', icon: <XCircle className="w-3.5 h-3.5" /> },
|
|
||||||
{ label: 'Remove Content', action: 'remove_content', confirm: true, color: 'bg-red-50 text-red-700 hover:bg-red-100', icon: <Trash2 className="w-3.5 h-3.5" /> },
|
|
||||||
]}
|
|
||||||
onAction={handleModBulk}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{modLoading ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{[...Array(3)].map((_, i) => <div key={i} className="card p-6 animate-pulse"><div className="h-20 bg-warm-300 rounded" /></div>)}
|
|
||||||
</div>
|
|
||||||
) : modItems.length === 0 ? (
|
|
||||||
<div className="card p-12 text-center">
|
|
||||||
<Shield className="w-12 h-12 text-green-400 mx-auto mb-3" />
|
|
||||||
<p className="text-gray-500 font-medium">No {modStatus} items in the queue</p>
|
|
||||||
<p className="text-sm text-gray-400 mt-1">All clear!</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{modItems.map((item) => (
|
|
||||||
<div key={item.id} className={`card p-5 ${modSelected.has(item.id) ? 'ring-2 ring-brand-300' : ''}`}>
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div className="flex items-start gap-3 flex-1">
|
|
||||||
{modStatus === 'pending' && (
|
|
||||||
<input type="checkbox" className="rounded border-gray-300 mt-1" checked={modSelected.has(item.id)}
|
|
||||||
onChange={() => setModSelected((prev) => { const s = new Set(prev); s.has(item.id) ? s.delete(item.id) : s.add(item.id); return s; })} />
|
|
||||||
)}
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className={`badge ${statusColor(item.status)}`}>{item.status}</span>
|
|
||||||
<span className="badge bg-gray-100 text-gray-600">{item.content_type}</span>
|
|
||||||
<span className="badge bg-red-50 text-red-700">
|
|
||||||
<AlertTriangle className="w-3 h-3 mr-1" />{item.flag_reason}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-gray-400 flex items-center gap-1"><Clock className="w-3 h-3" />{formatDateTime(item.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-warm-100 rounded-lg p-3 mb-3">
|
|
||||||
{item.content_type === 'post' ? (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-800 whitespace-pre-wrap">{item.post_body || 'No text content'}</p>
|
|
||||||
{item.post_image && <div className="mt-2 text-xs text-gray-400">Has image: {item.post_image}</div>}
|
|
||||||
{item.post_video && <div className="mt-1 text-xs text-gray-400">Has video: {item.post_video}</div>}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-gray-800">{item.comment_body || 'No content'}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contextual user info */}
|
|
||||||
<UserContextPanel userId={item.author_id} handle={item.author_handle} />
|
|
||||||
|
|
||||||
{item.scores && (
|
|
||||||
<div className="space-y-1 max-w-xs mt-3">
|
|
||||||
{item.scores.hate != null && <ScoreBar label="Hate" value={item.scores.hate} />}
|
|
||||||
{item.scores.greed != null && <ScoreBar label="Greed" value={item.scores.greed} />}
|
|
||||||
{item.scores.delusion != null && <ScoreBar label="Delusion" value={item.scores.delusion} />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{item.status === 'pending' && (
|
|
||||||
<div className="flex flex-col gap-2 flex-shrink-0">
|
|
||||||
<button onClick={() => handleModReview(item.id, 'approve')}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-2 bg-green-50 text-green-700 rounded-lg text-xs font-medium hover:bg-green-100">
|
|
||||||
<CheckCircle className="w-4 h-4" /> Approve
|
|
||||||
</button>
|
|
||||||
<button onClick={() => handleModReview(item.id, 'dismiss')}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-2 bg-gray-50 text-gray-600 rounded-lg text-xs font-medium hover:bg-gray-100">
|
|
||||||
<XCircle className="w-4 h-4" /> Dismiss
|
|
||||||
</button>
|
|
||||||
<button onClick={() => handleModReview(item.id, 'remove_content')}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-2 bg-red-50 text-red-700 rounded-lg text-xs font-medium hover:bg-red-100">
|
|
||||||
<Trash2 className="w-4 h-4" /> Remove
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setReviewingId(item.id)}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-2 bg-red-100 text-red-800 rounded-lg text-xs font-medium hover:bg-red-200">
|
|
||||||
<Ban className="w-4 h-4" /> Ban User
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{reviewingId === item.id && (
|
|
||||||
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg">
|
|
||||||
<p className="text-sm font-medium text-red-800 mb-2">Ban user and remove content</p>
|
|
||||||
<div className="space-y-1.5 mb-3">
|
|
||||||
{banReasons.map((preset) => (
|
|
||||||
<button key={preset}
|
|
||||||
onClick={() => { setBanReason(preset); setCustomBanReason(false); }}
|
|
||||||
className={`w-full text-left px-3 py-1.5 rounded text-xs border transition-colors ${
|
|
||||||
banReason === preset && !customBanReason
|
|
||||||
? 'border-red-400 bg-red-100 text-red-800 font-medium'
|
|
||||||
: 'border-red-200 hover:border-red-300 text-red-700'
|
|
||||||
}`}>{preset}</button>
|
|
||||||
))}
|
|
||||||
<button onClick={() => { setCustomBanReason(true); setBanReason(''); }}
|
|
||||||
className={`w-full text-left px-3 py-1.5 rounded text-xs border transition-colors ${
|
|
||||||
customBanReason ? 'border-red-400 bg-red-100 text-red-800 font-medium' : 'border-red-200 hover:border-red-300 text-red-700'
|
|
||||||
}`}>Custom reason...</button>
|
|
||||||
</div>
|
|
||||||
{customBanReason && (
|
|
||||||
<input className="input mb-2 text-sm" placeholder="Enter custom reason..." value={banReason} onChange={(e) => setBanReason(e.target.value)} autoFocus />
|
|
||||||
)}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button onClick={() => { setReviewingId(null); setBanReason(''); setCustomBanReason(false); }} className="btn-secondary text-xs">Cancel</button>
|
|
||||||
<button onClick={() => handleModReview(item.id, 'ban_user')} className="btn-danger text-xs" disabled={!banReason.trim()}>Confirm Ban</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ═══ APPEALS TAB ═══ */}
|
|
||||||
{tab === 'appeals' && (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<p className="text-sm text-gray-500">{appealsTotal} {appealsStatus} appeals</p>
|
|
||||||
<select className="input w-auto text-sm" value={appealsStatus} onChange={(e) => setAppealsStatus(e.target.value)}>
|
|
||||||
<option value="pending">Pending</option>
|
|
||||||
<option value="approved">Approved</option>
|
|
||||||
<option value="rejected">Rejected</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{appealsLoading ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{[...Array(3)].map((_, i) => <div key={i} className="card p-6 animate-pulse"><div className="h-24 bg-warm-300 rounded" /></div>)}
|
|
||||||
</div>
|
|
||||||
) : appeals.length === 0 ? (
|
|
||||||
<div className="card p-12 text-center">
|
|
||||||
<Scale className="w-12 h-12 text-green-400 mx-auto mb-3" />
|
|
||||||
<p className="text-gray-500 font-medium">No {appealsStatus} appeals</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{appeals.map((appeal) => (
|
|
||||||
<div key={appeal.id} className="card p-5">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<span className={`badge ${statusColor(appeal.status)}`}>{appeal.status}</span>
|
|
||||||
<span className="badge bg-orange-50 text-orange-700">{appeal.violation_type}</span>
|
|
||||||
<span className="text-xs text-gray-400 flex items-center gap-1"><Clock className="w-3 h-3" />{formatDateTime(appeal.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">Violation</h4>
|
|
||||||
<div className="bg-red-50 rounded-lg p-3 mb-3">
|
|
||||||
<p className="text-sm font-medium text-red-800">{appeal.violation_reason}</p>
|
|
||||||
{appeal.flag_reason && <p className="text-xs text-red-600 mt-1">Flag: {appeal.flag_reason}</p>}
|
|
||||||
<p className="text-xs text-red-500 mt-1">Severity: {(appeal.severity_score * 100).toFixed(0)}%</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(appeal.post_body || appeal.comment_body) && (
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-1">Original Content</h4>
|
|
||||||
<div className="bg-warm-100 rounded-lg p-3">
|
|
||||||
<p className="text-sm text-gray-700 whitespace-pre-wrap">{appeal.post_body || appeal.comment_body}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">Appeal by @{appeal.user?.handle || '—'}</h4>
|
|
||||||
<div className="bg-blue-50 rounded-lg p-3 mb-3">
|
|
||||||
<p className="text-sm text-blue-900 font-medium mb-1">Reason:</p>
|
|
||||||
<p className="text-sm text-blue-800">{appeal.appeal_reason}</p>
|
|
||||||
{appeal.appeal_context && (
|
|
||||||
<>
|
|
||||||
<p className="text-sm text-blue-900 font-medium mt-2 mb-1">Context:</p>
|
|
||||||
<p className="text-sm text-blue-800">{appeal.appeal_context}</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contextual user info */}
|
|
||||||
<UserContextPanel userId={appeal.user_id} handle={appeal.user?.handle} />
|
|
||||||
|
|
||||||
{appeal.flag_scores && (
|
|
||||||
<div className="text-xs text-gray-500 space-y-1 mt-3">
|
|
||||||
{Object.entries(appeal.flag_scores).map(([key, value]) => (
|
|
||||||
<div key={key} className="flex items-center gap-2">
|
|
||||||
<span className="w-16">{key}</span>
|
|
||||||
<div className="flex-1 h-1.5 bg-warm-300 rounded-full overflow-hidden">
|
|
||||||
<div className={`h-full rounded-full ${(value as number) > 0.7 ? 'bg-red-500' : (value as number) > 0.4 ? 'bg-yellow-500' : 'bg-green-500'}`}
|
|
||||||
style={{ width: `${(value as number) * 100}%` }} />
|
|
||||||
</div>
|
|
||||||
<span className="w-8 text-right font-mono">{((value as number) * 100).toFixed(0)}%</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{appeal.status === 'pending' && (
|
|
||||||
<>
|
|
||||||
{appealReviewId === appeal.id ? (
|
|
||||||
<div className="mt-4 p-4 bg-warm-100 border border-warm-400 rounded-lg">
|
|
||||||
<p className="text-sm font-medium text-gray-700 mb-2">Write your review decision:</p>
|
|
||||||
<textarea className="input mb-3" rows={3} placeholder="Explain your decision (min 5 chars)..."
|
|
||||||
value={appealDecision} onChange={(e) => setAppealDecision(e.target.value)} />
|
|
||||||
<label className="flex items-center gap-2 text-sm text-gray-600 mb-3">
|
|
||||||
<input type="checkbox" checked={restoreContent} onChange={(e) => setRestoreContent(e.target.checked)} className="rounded" />
|
|
||||||
Restore original content (if approving)
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button onClick={() => { setAppealReviewId(null); setAppealDecision(''); }} className="btn-secondary text-sm">Cancel</button>
|
|
||||||
<button onClick={() => handleAppealReview(appeal.id, 'approved')}
|
|
||||||
className="bg-green-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-green-700 flex items-center gap-1"
|
|
||||||
disabled={appealDecision.trim().length < 5}>
|
|
||||||
<CheckCircle className="w-4 h-4" /> Approve Appeal
|
|
||||||
</button>
|
|
||||||
<button onClick={() => handleAppealReview(appeal.id, 'rejected')}
|
|
||||||
className="btn-danger text-sm flex items-center gap-1"
|
|
||||||
disabled={appealDecision.trim().length < 5}>
|
|
||||||
<XCircle className="w-4 h-4" /> Reject Appeal
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mt-4 flex gap-2">
|
|
||||||
<button onClick={() => setAppealReviewId(appeal.id)} className="btn-primary text-sm flex items-center gap-1">
|
|
||||||
<Scale className="w-4 h-4" /> Review This Appeal
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{appeal.review_decision && (
|
|
||||||
<div className="mt-4 p-3 bg-warm-100 rounded-lg">
|
|
||||||
<p className="text-xs font-semibold text-gray-500 uppercase mb-1">Review Decision</p>
|
|
||||||
<p className="text-sm text-gray-700">{appeal.review_decision}</p>
|
|
||||||
{appeal.reviewed_at && <p className="text-xs text-gray-400 mt-1">Reviewed {formatDateTime(appeal.reviewed_at)}</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ═══ REPORTS TAB ═══ */}
|
|
||||||
{tab === 'reports' && (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<p className="text-sm text-gray-500">{reportsTotal} {reportsStatus} reports</p>
|
|
||||||
<select className="input w-auto text-sm" value={reportsStatus} onChange={(e) => { setReportsStatus(e.target.value); setReportsSelected(new Set()); }}>
|
|
||||||
<option value="pending">Pending</option>
|
|
||||||
<option value="reviewed">Reviewed</option>
|
|
||||||
<option value="actioned">Actioned</option>
|
|
||||||
<option value="dismissed">Dismissed</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{reportsStatus === 'pending' && (
|
|
||||||
<SelectionBar
|
|
||||||
count={reportsSelected.size}
|
|
||||||
total={reports.length}
|
|
||||||
onSelectAll={() => setReportsSelected(new Set(reports.map((r) => r.id)))}
|
|
||||||
onClearSelection={() => setReportsSelected(new Set())}
|
|
||||||
loading={reportsBulkLoading}
|
|
||||||
actions={[
|
|
||||||
{ label: 'Action All', action: 'actioned', color: 'bg-green-50 text-green-700 hover:bg-green-100', icon: <CheckCircle className="w-3.5 h-3.5" /> },
|
|
||||||
{ label: 'Dismiss All', action: 'dismissed', color: 'bg-gray-100 text-gray-700 hover:bg-gray-200', icon: <XCircle className="w-3.5 h-3.5" /> },
|
|
||||||
]}
|
|
||||||
onAction={handleReportsBulk}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{reportsLoading ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{[...Array(3)].map((_, i) => <div key={i} className="card p-6 animate-pulse"><div className="h-16 bg-warm-300 rounded" /></div>)}
|
|
||||||
</div>
|
|
||||||
) : reports.length === 0 ? (
|
|
||||||
<div className="card p-12 text-center">
|
|
||||||
<Flag className="w-12 h-12 text-green-400 mx-auto mb-3" />
|
|
||||||
<p className="text-gray-500 font-medium">No {reportsStatus} reports</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="card overflow-hidden">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead className="bg-warm-200">
|
|
||||||
<tr>
|
|
||||||
{reportsStatus === 'pending' && (
|
|
||||||
<th className="table-header w-10">
|
|
||||||
<input type="checkbox" className="rounded border-gray-300"
|
|
||||||
checked={reports.length > 0 && reportsSelected.size === reports.length}
|
|
||||||
onChange={() => {
|
|
||||||
if (reportsSelected.size === reports.length) setReportsSelected(new Set());
|
|
||||||
else setReportsSelected(new Set(reports.map((r) => r.id)));
|
|
||||||
}} />
|
|
||||||
</th>
|
|
||||||
)}
|
|
||||||
<th className="table-header">Reporter</th>
|
|
||||||
<th className="table-header">Target</th>
|
|
||||||
<th className="table-header">Type</th>
|
|
||||||
<th className="table-header">Description</th>
|
|
||||||
<th className="table-header">Content</th>
|
|
||||||
<th className="table-header">Status</th>
|
|
||||||
<th className="table-header">Date</th>
|
|
||||||
<th className="table-header">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-warm-300">
|
|
||||||
{reports.map((report) => (
|
|
||||||
<tr key={report.id} className={`hover:bg-warm-50 transition-colors ${reportsSelected.has(report.id) ? 'bg-brand-50' : ''}`}>
|
|
||||||
{reportsStatus === 'pending' && (
|
|
||||||
<td className="table-cell">
|
|
||||||
<input type="checkbox" className="rounded border-gray-300" checked={reportsSelected.has(report.id)}
|
|
||||||
onChange={() => setReportsSelected((prev) => { const s = new Set(prev); s.has(report.id) ? s.delete(report.id) : s.add(report.id); return s; })} />
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
<td className="table-cell">
|
|
||||||
<Link href={`/users/${report.reporter_id}`} className="text-brand-500 hover:text-brand-700 text-sm">
|
|
||||||
@{report.reporter_handle || '—'}
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
<td className="table-cell">
|
|
||||||
<Link href={`/users/${report.target_user_id}`} className="text-brand-500 hover:text-brand-700 text-sm">
|
|
||||||
@{report.target_handle || '—'}
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
<td className="table-cell">
|
|
||||||
<span className="badge bg-orange-50 text-orange-700">
|
|
||||||
<AlertTriangle className="w-3 h-3 mr-1" />{report.violation_type}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="table-cell max-w-xs">
|
|
||||||
<p className="text-sm text-gray-700 line-clamp-2">{report.description}</p>
|
|
||||||
</td>
|
|
||||||
<td className="table-cell text-xs text-gray-500">
|
|
||||||
{report.post_id && <Link href={`/posts/${report.post_id}`} className="text-brand-500 hover:text-brand-700">View Post</Link>}
|
|
||||||
</td>
|
|
||||||
<td className="table-cell">
|
|
||||||
<span className={`badge ${statusColor(report.status)}`}>{report.status}</span>
|
|
||||||
</td>
|
|
||||||
<td className="table-cell text-xs text-gray-500">{formatDateTime(report.created_at)}</td>
|
|
||||||
<td className="table-cell">
|
|
||||||
{report.status === 'pending' && (
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<button onClick={() => handleReportUpdate(report.id, 'actioned')}
|
|
||||||
className="p-1.5 bg-green-50 text-green-700 rounded hover:bg-green-100" title="Action taken">
|
|
||||||
<CheckCircle className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button onClick={() => handleReportUpdate(report.id, 'dismissed')}
|
|
||||||
className="p-1.5 bg-gray-50 text-gray-600 rounded hover:bg-gray-100" title="Dismiss">
|
|
||||||
<XCircle className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</AdminShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,410 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import AdminShell from '@/components/AdminShell';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { Mail, Send, ChevronLeft, Save, Eye, EyeOff, ArrowLeft } from 'lucide-react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
interface EmailTemplate {
|
|
||||||
id: string;
|
|
||||||
slug: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
subject: string;
|
|
||||||
title: string;
|
|
||||||
header: string;
|
|
||||||
content: string;
|
|
||||||
button_text: string;
|
|
||||||
button_url: string;
|
|
||||||
button_color: string;
|
|
||||||
footer: string;
|
|
||||||
text_body: string;
|
|
||||||
enabled: boolean;
|
|
||||||
updated_at: string;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TemplateCard({ template, onSelect }: { template: EmailTemplate; onSelect: () => void }) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onSelect}
|
|
||||||
className="card p-5 text-left hover:ring-2 hover:ring-brand-300 transition-all w-full"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<Mail className="w-4 h-4 text-brand-500 flex-shrink-0" />
|
|
||||||
<h3 className="font-semibold text-gray-900 truncate">{template.name}</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500 mb-2 line-clamp-2">{template.description}</p>
|
|
||||||
<p className="text-xs font-mono text-gray-400 truncate">Subject: {template.subject}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex-shrink-0 ml-3">
|
|
||||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${template.enabled ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
|
|
||||||
{template.enabled ? 'Active' : 'Disabled'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-400 mt-2">
|
|
||||||
Last updated: {new Date(template.updated_at).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TemplateEditor({ template, onBack, onSaved }: { template: EmailTemplate; onBack: () => void; onSaved: () => void }) {
|
|
||||||
const [form, setForm] = useState({ ...template });
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [testEmail, setTestEmail] = useState('');
|
|
||||||
const [sending, setSending] = useState(false);
|
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
setSaving(true);
|
|
||||||
setMessage(null);
|
|
||||||
try {
|
|
||||||
await api.updateEmailTemplate(template.id, {
|
|
||||||
subject: form.subject,
|
|
||||||
title: form.title,
|
|
||||||
header: form.header,
|
|
||||||
content: form.content,
|
|
||||||
button_text: form.button_text,
|
|
||||||
button_url: form.button_url,
|
|
||||||
button_color: form.button_color,
|
|
||||||
footer: form.footer,
|
|
||||||
text_body: form.text_body,
|
|
||||||
enabled: form.enabled,
|
|
||||||
});
|
|
||||||
setMessage({ type: 'success', text: 'Template saved successfully' });
|
|
||||||
onSaved();
|
|
||||||
} catch (err: any) {
|
|
||||||
setMessage({ type: 'error', text: err.message || 'Failed to save' });
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSendTest = async () => {
|
|
||||||
if (!testEmail) return;
|
|
||||||
setSending(true);
|
|
||||||
setMessage(null);
|
|
||||||
try {
|
|
||||||
await api.sendTestEmail(template.id, testEmail);
|
|
||||||
setMessage({ type: 'success', text: `Test email sent to ${testEmail}` });
|
|
||||||
} catch (err: any) {
|
|
||||||
setMessage({ type: 'error', text: err.message || 'Failed to send test email' });
|
|
||||||
} finally {
|
|
||||||
setSending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<button onClick={onBack} className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-900 mb-4 transition-colors">
|
|
||||||
<ArrowLeft className="w-4 h-4" /> Back to templates
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-bold text-gray-900">{template.name}</h2>
|
|
||||||
<p className="text-sm text-gray-500">{template.description}</p>
|
|
||||||
<p className="text-xs font-mono text-gray-400 mt-1">slug: {template.slug}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowPreview(!showPreview)}
|
|
||||||
className="btn-secondary text-sm flex items-center gap-1"
|
|
||||||
>
|
|
||||||
{showPreview ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
||||||
{showPreview ? 'Hide Preview' : 'Preview'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving}
|
|
||||||
className="btn-primary text-sm flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<Save className="w-4 h-4" />
|
|
||||||
{saving ? 'Saving...' : 'Save Changes'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div className={`mb-4 p-3 rounded-lg text-sm ${message.type === 'success' ? 'bg-green-50 text-green-700 border border-green-200' : 'bg-red-50 text-red-700 border border-red-200'}`}>
|
|
||||||
{message.text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
{/* Editor */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Enabled toggle */}
|
|
||||||
<div className="card p-4">
|
|
||||||
<label className="flex items-center gap-3 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={form.enabled}
|
|
||||||
onChange={(e) => setForm({ ...form, enabled: e.target.checked })}
|
|
||||||
className="w-4 h-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium text-gray-700">Email enabled</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Subject */}
|
|
||||||
<div className="card p-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Subject Line</label>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={form.subject}
|
|
||||||
onChange={(e) => setForm({ ...form, subject: e.target.value })}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-400 mt-1">Supports placeholders: {'{{name}}, {{reason}}, {{content_type}}, etc.'}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title & Header */}
|
|
||||||
<div className="card p-4 space-y-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email Title</label>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={form.title}
|
|
||||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-400 mt-1">Shown in the colored header banner</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Header Text</label>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={form.header}
|
|
||||||
onChange={(e) => setForm({ ...form, header: e.target.value })}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-400 mt-1">Large heading inside the email body</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content (HTML) */}
|
|
||||||
<div className="card p-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Content (HTML)</label>
|
|
||||||
<textarea
|
|
||||||
className="input font-mono text-xs"
|
|
||||||
rows={10}
|
|
||||||
value={form.content}
|
|
||||||
onChange={(e) => setForm({ ...form, content: e.target.value })}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-400 mt-1">HTML content for the email body. Use {'{{name}}, {{reason}}, {{verify_url}}'}, etc.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Button */}
|
|
||||||
<div className="card p-4 space-y-3">
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Button Text</label>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={form.button_text}
|
|
||||||
onChange={(e) => setForm({ ...form, button_text: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Button Color</label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={form.button_color}
|
|
||||||
onChange={(e) => setForm({ ...form, button_color: e.target.value })}
|
|
||||||
className="w-10 h-10 rounded border border-gray-300 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="input flex-1"
|
|
||||||
value={form.button_color}
|
|
||||||
onChange={(e) => setForm({ ...form, button_color: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Button URL</label>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={form.button_url}
|
|
||||||
onChange={(e) => setForm({ ...form, button_url: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="card p-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Footer (HTML, optional)</label>
|
|
||||||
<textarea
|
|
||||||
className="input font-mono text-xs"
|
|
||||||
rows={3}
|
|
||||||
value={form.footer}
|
|
||||||
onChange={(e) => setForm({ ...form, footer: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Plain text fallback */}
|
|
||||||
<div className="card p-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Plain Text Fallback</label>
|
|
||||||
<textarea
|
|
||||||
className="input font-mono text-xs"
|
|
||||||
rows={4}
|
|
||||||
value={form.text_body}
|
|
||||||
onChange={(e) => setForm({ ...form, text_body: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Send Test */}
|
|
||||||
<div className="card p-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Send Test Email</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
className="input flex-1"
|
|
||||||
type="email"
|
|
||||||
placeholder="test@example.com"
|
|
||||||
value={testEmail}
|
|
||||||
onChange={(e) => setTestEmail(e.target.value)}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleSendTest}
|
|
||||||
disabled={sending || !testEmail}
|
|
||||||
className="btn-primary text-sm flex items-center gap-1 whitespace-nowrap"
|
|
||||||
>
|
|
||||||
<Send className="w-4 h-4" />
|
|
||||||
{sending ? 'Sending...' : 'Send Test'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-400 mt-1">Sends the saved version with sample placeholder data</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Preview */}
|
|
||||||
{showPreview && (
|
|
||||||
<div className="card p-0 overflow-hidden sticky top-6 self-start">
|
|
||||||
<div className="bg-gray-100 px-4 py-2 border-b border-gray-200">
|
|
||||||
<p className="text-xs font-medium text-gray-500">Email Preview</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="mb-3 pb-3 border-b border-gray-100">
|
|
||||||
<p className="text-xs text-gray-400">Subject</p>
|
|
||||||
<p className="text-sm font-medium text-gray-900">{form.subject}</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="bg-white rounded-lg overflow-hidden border border-gray-200"
|
|
||||||
style={{ maxHeight: '600px', overflowY: 'auto' }}
|
|
||||||
>
|
|
||||||
{/* Simulated email header */}
|
|
||||||
<div className="text-center p-8" style={{ backgroundColor: '#4338CA' }}>
|
|
||||||
<img src="https://mp.ls/img/sojornlogo.png" alt="Sojorn" className="w-16 h-16 rounded-2xl mx-auto mb-3" />
|
|
||||||
<p className="text-white text-xs font-semibold tracking-wider uppercase">{form.title}</p>
|
|
||||||
</div>
|
|
||||||
{/* Content */}
|
|
||||||
<div className="p-6">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4 text-center">{form.header}</h2>
|
|
||||||
<div
|
|
||||||
className="text-sm text-gray-600 leading-relaxed mb-6 [&_p]:mb-3 [&_ul]:list-disc [&_ul]:pl-5 [&_li]:mb-1 [&_a]:text-indigo-600 [&_a]:underline"
|
|
||||||
dangerouslySetInnerHTML={{ __html: form.content }}
|
|
||||||
/>
|
|
||||||
{form.button_text && (
|
|
||||||
<div className="text-center mb-4">
|
|
||||||
<span
|
|
||||||
className="inline-block px-8 py-3 text-white font-semibold rounded-xl text-sm"
|
|
||||||
style={{ backgroundColor: form.button_color }}
|
|
||||||
>
|
|
||||||
{form.button_text}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{form.footer && (
|
|
||||||
<div
|
|
||||||
className="text-xs text-gray-400 [&_p]:mb-2"
|
|
||||||
dangerouslySetInnerHTML={{ __html: form.footer }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="bg-gray-50 border-t border-gray-200 p-4 text-center">
|
|
||||||
<p className="text-xs text-gray-400">© 2026 Sojorn by MPLS LLC. All rights reserved.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EmailSettingsPage() {
|
|
||||||
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [selected, setSelected] = useState<EmailTemplate | null>(null);
|
|
||||||
|
|
||||||
const loadTemplates = () => {
|
|
||||||
api.listEmailTemplates()
|
|
||||||
.then((data) => setTemplates(data.templates || []))
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => { loadTemplates(); }, []);
|
|
||||||
|
|
||||||
const handleSelect = (t: EmailTemplate) => {
|
|
||||||
api.getEmailTemplate(t.id)
|
|
||||||
.then((full) => setSelected(full))
|
|
||||||
.catch(() => setSelected(t));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminShell>
|
|
||||||
{selected ? (
|
|
||||||
<TemplateEditor
|
|
||||||
template={selected}
|
|
||||||
onBack={() => { setSelected(null); loadTemplates(); }}
|
|
||||||
onSaved={loadTemplates}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="mb-6">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<Link href="/settings" className="text-gray-400 hover:text-gray-600 transition-colors">
|
|
||||||
<ChevronLeft className="w-5 h-5" />
|
|
||||||
</Link>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Email Templates</h1>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
|
||||||
Manage the email templates sent for different app operations. Edit content, toggle emails on/off, and send test emails.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{[...Array(6)].map((_, i) => (
|
|
||||||
<div key={i} className="card p-5 animate-pulse">
|
|
||||||
<div className="h-4 bg-warm-300 rounded w-32 mb-3" />
|
|
||||||
<div className="h-3 bg-warm-300 rounded w-48 mb-2" />
|
|
||||||
<div className="h-3 bg-warm-300 rounded w-40" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : templates.length === 0 ? (
|
|
||||||
<div className="card p-8 text-center text-gray-500">
|
|
||||||
<Mail className="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
|
||||||
<p>No email templates found. Run the database migration to seed default templates.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{templates.map((t) => (
|
|
||||||
<TemplateCard key={t.id} template={t} onSelect={() => handleSelect(t)} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</AdminShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
|
||||||
|
|
||||||
interface AltchaProps {
|
|
||||||
challengeurl: string;
|
|
||||||
onVerified?: (payload: string) => void;
|
|
||||||
onError?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Altcha({ challengeurl, onVerified, onError }: AltchaProps) {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const callbacksRef = useRef({ onVerified, onError });
|
|
||||||
callbacksRef.current = { onVerified, onError };
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Load script if not already loaded
|
|
||||||
if (!document.querySelector('script[data-altcha]')) {
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.src = 'https://cdn.jsdelivr.net/npm/altcha@2.3.0/dist/altcha.min.js';
|
|
||||||
script.type = 'module';
|
|
||||||
script.async = true;
|
|
||||||
script.setAttribute('data-altcha', 'true');
|
|
||||||
document.head.appendChild(script);
|
|
||||||
}
|
|
||||||
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
// Create the widget element
|
|
||||||
container.innerHTML = `<altcha-widget challengeurl="${challengeurl}"></altcha-widget>`;
|
|
||||||
const widget = container.querySelector('altcha-widget');
|
|
||||||
if (!widget) return;
|
|
||||||
|
|
||||||
const handler = (e: Event) => {
|
|
||||||
const detail = (e as CustomEvent).detail;
|
|
||||||
console.log('[ALTCHA] statechange:', detail);
|
|
||||||
if (detail?.state === 'verified' && detail?.payload) {
|
|
||||||
callbacksRef.current.onVerified?.(detail.payload);
|
|
||||||
} else if (detail?.state === 'error') {
|
|
||||||
callbacksRef.current.onError?.();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
widget.addEventListener('statechange', handler);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
widget.removeEventListener('statechange', handler);
|
|
||||||
};
|
|
||||||
}, [challengeurl]);
|
|
||||||
|
|
||||||
return <div ref={containerRef} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -8,7 +8,7 @@ import {
|
||||||
LayoutDashboard, Users, FileText, Shield, ShieldCheck, Scale, Flag,
|
LayoutDashboard, Users, FileText, Shield, ShieldCheck, Scale, Flag,
|
||||||
Settings, Activity, LogOut, ChevronLeft, ChevronRight, ChevronDown,
|
Settings, Activity, LogOut, ChevronLeft, ChevronRight, ChevronDown,
|
||||||
Sliders, FolderTree, HardDrive, AtSign, Brain, ScrollText, Wrench, Bot,
|
Sliders, FolderTree, HardDrive, AtSign, Brain, ScrollText, Wrench, Bot,
|
||||||
UserCog, ShieldAlert, Cog, Mail, MapPinned,
|
UserCog, ShieldAlert, Cog,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
|
@ -29,7 +29,6 @@ const navigation: NavEntry[] = [
|
||||||
{ href: '/users', label: 'Users', icon: Users },
|
{ href: '/users', label: 'Users', icon: Users },
|
||||||
{ href: '/posts', label: 'Posts', icon: FileText },
|
{ href: '/posts', label: 'Posts', icon: FileText },
|
||||||
{ href: '/categories', label: 'Categories', icon: FolderTree },
|
{ href: '/categories', label: 'Categories', icon: FolderTree },
|
||||||
{ href: '/neighborhoods', label: 'Neighborhoods', icon: MapPinned },
|
|
||||||
{ href: '/official-accounts', label: 'Official Accounts', icon: Bot },
|
{ href: '/official-accounts', label: 'Official Accounts', icon: Bot },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -37,7 +36,6 @@ const navigation: NavEntry[] = [
|
||||||
label: 'Moderation & Safety',
|
label: 'Moderation & Safety',
|
||||||
icon: ShieldAlert,
|
icon: ShieldAlert,
|
||||||
items: [
|
items: [
|
||||||
{ href: '/safety', label: 'Safety Workspace', icon: ShieldAlert },
|
|
||||||
{ href: '/moderation', label: 'Moderation Queue', icon: Shield },
|
{ href: '/moderation', label: 'Moderation Queue', icon: Shield },
|
||||||
{ href: '/ai-moderation', label: 'AI Moderation', icon: Brain },
|
{ href: '/ai-moderation', label: 'AI Moderation', icon: Brain },
|
||||||
{ href: '/ai-audit-log', label: 'AI Audit Log', icon: ScrollText },
|
{ href: '/ai-audit-log', label: 'AI Audit Log', icon: ScrollText },
|
||||||
|
|
@ -55,7 +53,6 @@ const navigation: NavEntry[] = [
|
||||||
{ href: '/usernames', label: 'Usernames', icon: AtSign },
|
{ href: '/usernames', label: 'Usernames', icon: AtSign },
|
||||||
{ href: '/storage', label: 'Storage', icon: HardDrive },
|
{ href: '/storage', label: 'Storage', icon: HardDrive },
|
||||||
{ href: '/system', label: 'System Health', icon: Activity },
|
{ href: '/system', label: 'System Health', icon: Activity },
|
||||||
{ href: '/settings/emails', label: 'Email Templates', icon: Mail },
|
|
||||||
{ href: '/settings', label: 'Settings', icon: Settings },
|
{ href: '/settings', label: 'Settings', icon: Settings },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -52,9 +52,9 @@ class ApiClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
async login(email: string, password: string, altchaToken?: string) {
|
async login(email: string, password: string, turnstileToken?: string) {
|
||||||
const body: Record<string, string> = { email, password };
|
const body: Record<string, string> = { email, password };
|
||||||
if (altchaToken) body.altcha_token = altchaToken;
|
if (turnstileToken) body.turnstile_token = turnstileToken;
|
||||||
const data = await this.request<{ access_token: string; user: any }>('/api/v1/admin/login', {
|
const data = await this.request<{ access_token: string; user: any }>('/api/v1/admin/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
|
@ -263,64 +263,6 @@ class ApiClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Neighborhoods
|
|
||||||
async listNeighborhoods(params: {
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
search?: string;
|
|
||||||
zip?: string;
|
|
||||||
sort?: 'name' | 'zip' | 'members' | 'created';
|
|
||||||
order?: 'asc' | 'desc';
|
|
||||||
} = {}) {
|
|
||||||
const qs = new URLSearchParams();
|
|
||||||
if (params.limit) qs.set('limit', String(params.limit));
|
|
||||||
if (params.offset) qs.set('offset', String(params.offset));
|
|
||||||
if (params.search) qs.set('search', params.search);
|
|
||||||
if (params.zip) qs.set('zip', params.zip);
|
|
||||||
if (params.sort) qs.set('sort', params.sort);
|
|
||||||
if (params.order) qs.set('order', params.order);
|
|
||||||
return this.request<any>(`/api/v1/admin/neighborhoods?${qs}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async setNeighborhoodAdmin(id: string, userId: string, action: 'assign' | 'remove') {
|
|
||||||
return this.request<any>(`/api/v1/admin/neighborhoods/${id}/admins`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ user_id: userId, action }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async listNeighborhoodAdmins(id: string) {
|
|
||||||
return this.request<any>(`/api/v1/admin/neighborhoods/${id}/admins`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async listNeighborhoodBoardEntries(id: string, params: {
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
search?: string;
|
|
||||||
active?: 'true' | 'false';
|
|
||||||
} = {}) {
|
|
||||||
const qs = new URLSearchParams();
|
|
||||||
if (params.limit) qs.set('limit', String(params.limit));
|
|
||||||
if (params.offset) qs.set('offset', String(params.offset));
|
|
||||||
if (params.search) qs.set('search', params.search);
|
|
||||||
if (params.active) qs.set('active', params.active);
|
|
||||||
return this.request<any>(`/api/v1/admin/neighborhoods/${id}/board?${qs}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateNeighborhoodBoardEntry(id: string, entryId: string, isActive: boolean) {
|
|
||||||
return this.request<any>(`/api/v1/admin/neighborhoods/${id}/board/${entryId}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify({ is_active: isActive }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async pinNeighborhoodBoardEntry(id: string, entryId: string, isPinned: boolean) {
|
|
||||||
return this.request<any>(`/api/v1/admin/neighborhoods/${id}/board/${entryId}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify({ is_pinned: isPinned }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// System
|
// System
|
||||||
async getSystemHealth() {
|
async getSystemHealth() {
|
||||||
return this.request<any>('/api/v1/admin/health');
|
return this.request<any>('/api/v1/admin/health');
|
||||||
|
|
@ -403,11 +345,6 @@ class ApiClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI Engines
|
|
||||||
async getAIEngines() {
|
|
||||||
return this.request<any>('/api/v1/admin/ai-engines');
|
|
||||||
}
|
|
||||||
|
|
||||||
// AI Moderation
|
// AI Moderation
|
||||||
async listOpenRouterModels(params: { capability?: string; search?: string } = {}) {
|
async listOpenRouterModels(params: { capability?: string; search?: string } = {}) {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
|
|
@ -416,52 +353,24 @@ class ApiClient {
|
||||||
return this.request<any>(`/api/v1/admin/ai/models?${qs}`);
|
return this.request<any>(`/api/v1/admin/ai/models?${qs}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listLocalModels() {
|
|
||||||
return this.request<any>('/api/v1/admin/ai/models/local');
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAIModerationConfigs() {
|
async getAIModerationConfigs() {
|
||||||
return this.request<any>('/api/v1/admin/ai/config');
|
return this.request<any>('/api/v1/admin/ai/config');
|
||||||
}
|
}
|
||||||
|
|
||||||
async setAIModerationConfig(data: { moderation_type: string; model_id: string; model_name: string; system_prompt: string; enabled: boolean; engines?: string[] }) {
|
async setAIModerationConfig(data: { moderation_type: string; model_id: string; model_name: string; system_prompt: string; enabled: boolean }) {
|
||||||
return this.request<any>('/api/v1/admin/ai/config', {
|
return this.request<any>('/api/v1/admin/ai/config', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async testAIModeration(data: { moderation_type: string; content?: string; image_url?: string; engine?: string }) {
|
async testAIModeration(data: { moderation_type: string; content?: string; image_url?: string }) {
|
||||||
return this.request<any>('/api/v1/admin/ai/test', {
|
return this.request<any>('/api/v1/admin/ai/test', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadTestImage(file: File) {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
const token = this.getToken();
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
if (token) {
|
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}/api/v1/admin/upload-test-image`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
throw new Error(`Upload failed: ${response.status} - ${errorText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// AI Moderation Audit Log
|
// AI Moderation Audit Log
|
||||||
async getAIModerationLog(params: { limit?: number; offset?: number; decision?: string; content_type?: string; search?: string; feedback?: string } = {}) {
|
async getAIModerationLog(params: { limit?: number; offset?: number; decision?: string; content_type?: string; search?: string; feedback?: string } = {}) {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
|
|
@ -551,25 +460,6 @@ class ApiClient {
|
||||||
return this.request<any>(`/api/v1/admin/official-accounts/${id}/posted?limit=${limit}&status=${status}`);
|
return this.request<any>(`/api/v1/admin/official-accounts/${id}/posted?limit=${limit}&status=${status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async skipArticle(articleId: string) {
|
|
||||||
return this.request<any>(`/api/v1/admin/official-accounts/articles/${articleId}/skip`, { method: 'POST' });
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteArticle(articleId: string) {
|
|
||||||
return this.request<any>(`/api/v1/admin/official-accounts/articles/${articleId}`, { method: 'DELETE' });
|
|
||||||
}
|
|
||||||
|
|
||||||
async postSpecificArticle(articleId: string) {
|
|
||||||
return this.request<any>(`/api/v1/admin/official-accounts/articles/${articleId}/post`, { method: 'POST' });
|
|
||||||
}
|
|
||||||
|
|
||||||
async cleanupPendingArticles(configId: string, before: string, action: 'skip' | 'delete') {
|
|
||||||
return this.request<any>(`/api/v1/admin/official-accounts/${configId}/articles/cleanup`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ before, action }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async adminImportContent(data: {
|
async adminImportContent(data: {
|
||||||
author_id: string;
|
author_id: string;
|
||||||
content_type: string;
|
content_type: string;
|
||||||
|
|
@ -610,29 +500,6 @@ class ApiClient {
|
||||||
async checkURLSafety(url: string) {
|
async checkURLSafety(url: string) {
|
||||||
return this.request<any>(`/api/v1/admin/safe-domains/check?url=${encodeURIComponent(url)}`);
|
return this.request<any>(`/api/v1/admin/safe-domains/check?url=${encodeURIComponent(url)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Email Templates
|
|
||||||
async listEmailTemplates() {
|
|
||||||
return this.request<any>('/api/v1/admin/email-templates');
|
|
||||||
}
|
|
||||||
|
|
||||||
async getEmailTemplate(id: string) {
|
|
||||||
return this.request<any>(`/api/v1/admin/email-templates/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateEmailTemplate(id: string, data: Record<string, any>) {
|
|
||||||
return this.request<any>(`/api/v1/admin/email-templates/${id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendTestEmail(templateId: string, toEmail: string) {
|
|
||||||
return this.request<any>('/api/v1/admin/email-templates/test', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ template_id: templateId, to_email: toEmail }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = new ApiClient();
|
export const api = new ApiClient();
|
||||||
|
|
|
||||||
9
admin/src/types/altcha.d.ts
vendored
9
admin/src/types/altcha.d.ts
vendored
|
|
@ -1,9 +0,0 @@
|
||||||
declare namespace JSX {
|
|
||||||
interface IntrinsicElements {
|
|
||||||
'altcha-widget': {
|
|
||||||
challengeurl?: string;
|
|
||||||
hidefooter?: string;
|
|
||||||
hidelogo?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,341 +0,0 @@
|
||||||
package gateway
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"ai-gateway/internal/config"
|
|
||||||
"ai-gateway/internal/ollama"
|
|
||||||
"ai-gateway/internal/queue"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Handler struct {
|
|
||||||
cfg *config.Config
|
|
||||||
q *queue.Queue
|
|
||||||
ollama *ollama.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(cfg *config.Config, q *queue.Queue, oc *ollama.Client) *Handler {
|
|
||||||
return &Handler{cfg: cfg, q: q, ollama: oc}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
||||||
path := r.URL.Path
|
|
||||||
switch {
|
|
||||||
case path == "/healthz" && r.Method == "GET":
|
|
||||||
h.healthz(w, r)
|
|
||||||
case path == "/readyz" && r.Method == "GET":
|
|
||||||
h.readyz(w, r)
|
|
||||||
case path == "/v1/moderate" && r.Method == "POST":
|
|
||||||
h.authMiddleware(h.moderate)(w, r)
|
|
||||||
case path == "/v1/generate" && r.Method == "POST":
|
|
||||||
h.authMiddleware(h.generate)(w, r)
|
|
||||||
case strings.HasPrefix(path, "/v1/jobs/") && r.Method == "GET":
|
|
||||||
h.authMiddleware(h.getJob)(w, r)
|
|
||||||
default:
|
|
||||||
jsonError(w, http.StatusNotFound, "not found")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if h.cfg.InternalToken == "" {
|
|
||||||
next(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
token := r.Header.Get("X-Internal-Token")
|
|
||||||
if token != h.cfg.InternalToken {
|
|
||||||
jsonError(w, http.StatusUnauthorized, "unauthorized")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next(w, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) healthz(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
jsonOK(w, map[string]any{"status": "ok", "time": time.Now().UTC()})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) readyz(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
status := map[string]any{"time": time.Now().UTC()}
|
|
||||||
ready := true
|
|
||||||
|
|
||||||
if err := h.q.Ping(ctx); err != nil {
|
|
||||||
status["redis"] = "down"
|
|
||||||
ready = false
|
|
||||||
} else {
|
|
||||||
status["redis"] = "ok"
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.ollama.Healthz(ctx); err != nil {
|
|
||||||
status["ollama"] = "down"
|
|
||||||
} else {
|
|
||||||
status["ollama"] = "ok"
|
|
||||||
}
|
|
||||||
|
|
||||||
status["ollama_circuit"] = h.ollama.IsAvailable()
|
|
||||||
|
|
||||||
writerLen, _ := h.q.QueueLen(ctx, "writer")
|
|
||||||
judgeLen, _ := h.q.QueueLen(ctx, "judge")
|
|
||||||
status["queue_writer"] = writerLen
|
|
||||||
status["queue_judge"] = judgeLen
|
|
||||||
|
|
||||||
if ready {
|
|
||||||
status["status"] = "ready"
|
|
||||||
jsonOK(w, status)
|
|
||||||
} else {
|
|
||||||
status["status"] = "not_ready"
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
|
||||||
json.NewEncoder(w).Encode(status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ModerateRequest struct {
|
|
||||||
Text string `json:"text"`
|
|
||||||
Context map[string]any `json:"context,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) moderate(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if h.cfg.AIDisabled {
|
|
||||||
jsonOK(w, map[string]any{"allowed": true, "reason": "ai_disabled", "cached": false})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req ModerateRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
jsonError(w, http.StatusBadRequest, "invalid json")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Text == "" {
|
|
||||||
jsonError(w, http.StatusBadRequest, "text required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rate limit
|
|
||||||
ok, err := h.q.CheckRate(r.Context(), "global", "moderate", h.cfg.ModerateRateLimit)
|
|
||||||
if err != nil || !ok {
|
|
||||||
jsonError(w, http.StatusTooManyRequests, "rate limit exceeded")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check cache
|
|
||||||
if cached, err := h.q.GetModCache(r.Context(), req.Text); err == nil {
|
|
||||||
var result map[string]any
|
|
||||||
if json.Unmarshal(cached, &result) == nil {
|
|
||||||
result["cached"] = true
|
|
||||||
jsonOK(w, result)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Synchronous fast path for short texts
|
|
||||||
if len(req.Text) <= h.cfg.SyncMaxChars && h.ollama.IsAvailable() {
|
|
||||||
result, err := h.runJudge(r.Context(), req.Text)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[moderate] sync judge error: %v", err)
|
|
||||||
// Fail open
|
|
||||||
jsonOK(w, map[string]any{"allowed": true, "reason": "judge_error", "error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if data, err := json.Marshal(result); err == nil {
|
|
||||||
h.q.SetModCache(r.Context(), req.Text, data)
|
|
||||||
}
|
|
||||||
result["cached"] = false
|
|
||||||
jsonOK(w, result)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Async path for long texts
|
|
||||||
jobID := uuid.New().String()
|
|
||||||
input, _ := json.Marshal(req)
|
|
||||||
job := &queue.Job{
|
|
||||||
ID: jobID,
|
|
||||||
Type: "judge",
|
|
||||||
CreatedAt: time.Now().UTC(),
|
|
||||||
Input: input,
|
|
||||||
Status: "queued",
|
|
||||||
}
|
|
||||||
if err := h.q.Enqueue(r.Context(), job); err != nil {
|
|
||||||
jsonError(w, http.StatusInternalServerError, "queue error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusAccepted)
|
|
||||||
json.NewEncoder(w).Encode(map[string]any{"job_id": jobID, "status": "queued"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// guardCategories maps LLaMA Guard 3 S-codes to human-readable category names.
|
|
||||||
var guardCategories = map[string]string{
|
|
||||||
"S1": "violent_crimes",
|
|
||||||
"S2": "non_violent_crimes",
|
|
||||||
"S3": "sex_related_crimes",
|
|
||||||
"S4": "child_sexual_exploitation",
|
|
||||||
"S5": "defamation",
|
|
||||||
"S6": "specialized_advice",
|
|
||||||
"S7": "privacy",
|
|
||||||
"S8": "intellectual_property",
|
|
||||||
"S9": "indiscriminate_weapons",
|
|
||||||
"S10": "hate",
|
|
||||||
"S11": "suicide_self_harm",
|
|
||||||
"S12": "sexual_content",
|
|
||||||
"S13": "elections",
|
|
||||||
"S14": "code_interpreter_abuse",
|
|
||||||
}
|
|
||||||
|
|
||||||
// highSeverityCodes are categories that should always be severity "high".
|
|
||||||
var highSeverityCodes = map[string]bool{"S1": true, "S3": true, "S4": true, "S9": true}
|
|
||||||
|
|
||||||
// parseGuardOutput parses LLaMA Guard 3's native output format.
|
|
||||||
// Safe output: "safe"
|
|
||||||
// Unsafe output: "unsafe\nS1,S4" or "unsafe\nS1"
|
|
||||||
func parseGuardOutput(raw string) map[string]any {
|
|
||||||
content := strings.TrimSpace(raw)
|
|
||||||
lower := strings.ToLower(content)
|
|
||||||
|
|
||||||
if lower == "safe" || strings.HasPrefix(lower, "safe\n") || strings.HasPrefix(lower, "safe ") {
|
|
||||||
return map[string]any{"allowed": true, "categories": []string{}, "severity": "low", "reason": ""}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse "unsafe\nS1,S2,..."
|
|
||||||
categories := []string{}
|
|
||||||
codes := []string{}
|
|
||||||
severity := "medium"
|
|
||||||
|
|
||||||
lines := strings.Split(content, "\n")
|
|
||||||
if len(lines) > 1 {
|
|
||||||
// Second line has comma-separated S-codes
|
|
||||||
parts := strings.Split(strings.TrimSpace(lines[1]), ",")
|
|
||||||
for _, p := range parts {
|
|
||||||
code := strings.TrimSpace(p)
|
|
||||||
if code == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
codes = append(codes, code)
|
|
||||||
if name, ok := guardCategories[code]; ok {
|
|
||||||
categories = append(categories, name)
|
|
||||||
} else {
|
|
||||||
categories = append(categories, code)
|
|
||||||
}
|
|
||||||
if highSeverityCodes[code] {
|
|
||||||
severity = "high"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(categories) == 0 {
|
|
||||||
categories = []string{"policy_violation"}
|
|
||||||
}
|
|
||||||
|
|
||||||
return map[string]any{
|
|
||||||
"allowed": false,
|
|
||||||
"categories": categories,
|
|
||||||
"codes": codes,
|
|
||||||
"severity": severity,
|
|
||||||
"reason": strings.Join(categories, ", "),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) runJudge(ctx context.Context, text string) (map[string]any, error) {
|
|
||||||
resp, err := h.ollama.Chat(ctx, &ollama.ChatRequest{
|
|
||||||
Model: "llama-guard3:1b",
|
|
||||||
Messages: []ollama.ChatMessage{
|
|
||||||
{Role: "user", Content: text},
|
|
||||||
},
|
|
||||||
Stream: false,
|
|
||||||
Options: &ollama.ModelOptions{
|
|
||||||
Temperature: 0.0,
|
|
||||||
NumPredict: 64,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseGuardOutput(resp.Message.Content), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type GenerateRequest struct {
|
|
||||||
Task string `json:"task"`
|
|
||||||
Input map[string]any `json:"input"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) generate(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if h.cfg.AIDisabled {
|
|
||||||
jsonError(w, http.StatusServiceUnavailable, "ai_disabled")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req GenerateRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
jsonError(w, http.StatusBadRequest, "invalid json")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Task == "" {
|
|
||||||
jsonError(w, http.StatusBadRequest, "task required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, err := h.q.CheckRate(r.Context(), "global", "generate", h.cfg.GenerateRateLimit)
|
|
||||||
if err != nil || !ok {
|
|
||||||
jsonError(w, http.StatusTooManyRequests, "rate limit exceeded")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
jobID := uuid.New().String()
|
|
||||||
input, _ := json.Marshal(req)
|
|
||||||
job := &queue.Job{
|
|
||||||
ID: jobID,
|
|
||||||
Type: "writer",
|
|
||||||
CreatedAt: time.Now().UTC(),
|
|
||||||
Input: input,
|
|
||||||
Status: "queued",
|
|
||||||
}
|
|
||||||
if err := h.q.Enqueue(r.Context(), job); err != nil {
|
|
||||||
jsonError(w, http.StatusInternalServerError, "queue error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusAccepted)
|
|
||||||
json.NewEncoder(w).Encode(map[string]any{"job_id": jobID, "status": "queued"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) getJob(w http.ResponseWriter, r *http.Request) {
|
|
||||||
jobID := strings.TrimPrefix(r.URL.Path, "/v1/jobs/")
|
|
||||||
if jobID == "" {
|
|
||||||
jsonError(w, http.StatusBadRequest, "job_id required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
job, err := h.q.GetJob(r.Context(), jobID)
|
|
||||||
if err != nil {
|
|
||||||
jsonError(w, http.StatusNotFound, "job not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonOK(w, job)
|
|
||||||
}
|
|
||||||
|
|
||||||
func jsonOK(w http.ResponseWriter, data any) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func jsonError(w http.ResponseWriter, code int, msg string) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(code)
|
|
||||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
// Ensure fmt is used (prevent import error in case)
|
|
||||||
_ = fmt.Sprintf
|
|
||||||
}
|
|
||||||
|
|
@ -1,176 +0,0 @@
|
||||||
package worker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"log"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"ai-gateway/internal/ollama"
|
|
||||||
"ai-gateway/internal/queue"
|
|
||||||
)
|
|
||||||
|
|
||||||
type JudgeWorker struct {
|
|
||||||
q *queue.Queue
|
|
||||||
ollama *ollama.Client
|
|
||||||
concurrency int
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewJudge(q *queue.Queue, oc *ollama.Client, concurrency int) *JudgeWorker {
|
|
||||||
return &JudgeWorker{q: q, ollama: oc, concurrency: concurrency}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *JudgeWorker) Run(ctx context.Context) {
|
|
||||||
for i := 0; i < w.concurrency; i++ {
|
|
||||||
go w.loop(ctx, i)
|
|
||||||
}
|
|
||||||
<-ctx.Done()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *JudgeWorker) loop(ctx context.Context, workerID int) {
|
|
||||||
log.Printf("[judge-worker-%d] started", workerID)
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
log.Printf("[judge-worker-%d] shutting down", workerID)
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
job, err := w.q.Dequeue(ctx, "judge", 5*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("[judge-worker-%d] processing job %s", workerID, job.ID)
|
|
||||||
w.process(ctx, job)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *JudgeWorker) process(ctx context.Context, job *queue.Job) {
|
|
||||||
job.Status = "running"
|
|
||||||
w.q.UpdateJob(ctx, job)
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Text string `json:"text"`
|
|
||||||
Context map[string]any `json:"context,omitempty"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(job.Input, &req); err != nil {
|
|
||||||
job.Status = "failed"
|
|
||||||
job.Error = "invalid input: " + err.Error()
|
|
||||||
w.q.UpdateJob(ctx, job)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
timeoutCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
result, err := w.judge(timeoutCtx, req.Text)
|
|
||||||
if err != nil {
|
|
||||||
job.Status = "failed"
|
|
||||||
job.Error = err.Error()
|
|
||||||
w.q.UpdateJob(ctx, job)
|
|
||||||
log.Printf("[judge-worker] job %s failed: %v", job.ID, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resultJSON, _ := json.Marshal(result)
|
|
||||||
job.Status = "succeeded"
|
|
||||||
job.Result = resultJSON
|
|
||||||
w.q.UpdateJob(ctx, job)
|
|
||||||
|
|
||||||
// Cache result
|
|
||||||
if data, err := json.Marshal(result); err == nil {
|
|
||||||
w.q.SetModCache(ctx, req.Text, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("[judge-worker] job %s succeeded", job.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// guardCategories maps LLaMA Guard 3 S-codes to human-readable category names.
|
|
||||||
var guardCategories = map[string]string{
|
|
||||||
"S1": "violent_crimes",
|
|
||||||
"S2": "non_violent_crimes",
|
|
||||||
"S3": "sex_related_crimes",
|
|
||||||
"S4": "child_sexual_exploitation",
|
|
||||||
"S5": "defamation",
|
|
||||||
"S6": "specialized_advice",
|
|
||||||
"S7": "privacy",
|
|
||||||
"S8": "intellectual_property",
|
|
||||||
"S9": "indiscriminate_weapons",
|
|
||||||
"S10": "hate",
|
|
||||||
"S11": "suicide_self_harm",
|
|
||||||
"S12": "sexual_content",
|
|
||||||
"S13": "elections",
|
|
||||||
"S14": "code_interpreter_abuse",
|
|
||||||
}
|
|
||||||
|
|
||||||
var highSeverityCodes = map[string]bool{"S1": true, "S3": true, "S4": true, "S9": true}
|
|
||||||
|
|
||||||
func parseGuardOutput(raw string) map[string]any {
|
|
||||||
content := strings.TrimSpace(raw)
|
|
||||||
lower := strings.ToLower(content)
|
|
||||||
|
|
||||||
if lower == "safe" || strings.HasPrefix(lower, "safe\n") || strings.HasPrefix(lower, "safe ") {
|
|
||||||
return map[string]any{"allowed": true, "categories": []string{}, "severity": "low", "reason": ""}
|
|
||||||
}
|
|
||||||
|
|
||||||
categories := []string{}
|
|
||||||
codes := []string{}
|
|
||||||
severity := "medium"
|
|
||||||
|
|
||||||
lines := strings.Split(content, "\n")
|
|
||||||
if len(lines) > 1 {
|
|
||||||
parts := strings.Split(strings.TrimSpace(lines[1]), ",")
|
|
||||||
for _, p := range parts {
|
|
||||||
code := strings.TrimSpace(p)
|
|
||||||
if code == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
codes = append(codes, code)
|
|
||||||
if name, ok := guardCategories[code]; ok {
|
|
||||||
categories = append(categories, name)
|
|
||||||
} else {
|
|
||||||
categories = append(categories, code)
|
|
||||||
}
|
|
||||||
if highSeverityCodes[code] {
|
|
||||||
severity = "high"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(categories) == 0 {
|
|
||||||
categories = []string{"policy_violation"}
|
|
||||||
}
|
|
||||||
|
|
||||||
return map[string]any{
|
|
||||||
"allowed": false,
|
|
||||||
"categories": categories,
|
|
||||||
"codes": codes,
|
|
||||||
"severity": severity,
|
|
||||||
"reason": strings.Join(categories, ", "),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *JudgeWorker) judge(ctx context.Context, text string) (map[string]any, error) {
|
|
||||||
resp, err := w.ollama.Chat(ctx, &ollama.ChatRequest{
|
|
||||||
Model: "llama-guard3:1b",
|
|
||||||
Messages: []ollama.ChatMessage{
|
|
||||||
{Role: "user", Content: text},
|
|
||||||
},
|
|
||||||
Stream: false,
|
|
||||||
Options: &ollama.ModelOptions{
|
|
||||||
Temperature: 0.0,
|
|
||||||
NumPredict: 64,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseGuardOutput(resp.Message.Content), nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
listen [::]:80;
|
|
||||||
server_name beta.sojorn.net;
|
|
||||||
return 301 https://$host$request_uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 443 ssl http2;
|
|
||||||
listen [::]:443 ssl http2;
|
|
||||||
server_name beta.sojorn.net;
|
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/beta.sojorn.net/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/letsencrypt/live/beta.sojorn.net/privkey.pem;
|
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
|
||||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
|
||||||
|
|
||||||
root /var/www/beta.sojorn.net;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
|
||||||
expires 1y;
|
|
||||||
add_header Cache-Control "public, immutable";
|
|
||||||
}
|
|
||||||
|
|
||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
|
||||||
add_header X-XSS-Protection "1; mode=block" always;
|
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
|
||||||
|
|
||||||
gzip on;
|
|
||||||
gzip_vary on;
|
|
||||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
|
||||||
}
|
|
||||||
39
cloud_backup_status.md
Normal file
39
cloud_backup_status.md
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
# Cloud Backup Implementation Plan (Complete)
|
||||||
|
|
||||||
|
## 1. Frontend Implementation (Flutter)
|
||||||
|
- [x] **ApiService Updates**: Added `uploadBackup` and `downloadBackup` methods to interact with the backend (endpoints `/backups/upload` and `/backups/download`).
|
||||||
|
- [x] **LocalKeyBackupService Refactor**:
|
||||||
|
- [x] Updated `createEncryptedBackup` to accept `includeKeys` and `includeMessages` flags.
|
||||||
|
- [x] Added `uploadToCloud` method which **defaults to Messages Only** (no keys) for security.
|
||||||
|
- [x] Added `restoreFromCloud` method to fetch and decrypt backups.
|
||||||
|
- [x] **UI Overhaul (LocalBackupScreen)**:
|
||||||
|
- [x] Added "Cloud Mode" vs "Local Mode" toggle.
|
||||||
|
- [x] Implemented "Zero Knowledge" warning UI when Cloud Mode is active (keys excluded by default).
|
||||||
|
- [x] Added visual cues for "Secure Mode".
|
||||||
|
- [x] Integrated `uploadToCloud` and `restoreFromCloud` calls with progress indicators and error handling.
|
||||||
|
|
||||||
|
## 2. Backend Implementation (Go)
|
||||||
|
- [x] **Database Schema**: Created migration `000003_e2ee_backup_recovery.up.sql` for:
|
||||||
|
- `cloud_backups` table (stores encrypted blobs).
|
||||||
|
- `backup_preferences` table.
|
||||||
|
- `user_devices` table.
|
||||||
|
- `sync_codes` table.
|
||||||
|
- `recovery_guardians` and `recovery_sessions` tables (for future social recovery).
|
||||||
|
- [x] **API Endpoints**:
|
||||||
|
- `POST /backups/upload`: Accepts encrypted blob, metadata, and version.
|
||||||
|
- `GET /backups/download`: Retrieves latest backup.
|
||||||
|
- `GET /backups/download/:backup_id`: Retrieves specific backup.
|
||||||
|
- [x] **Data Models**: Defined `CloudBackup`, `UploadBackupRequest`, `DownloadBackupResponse` structs matching frontend expectations.
|
||||||
|
- [x] **Handler Logic**: Implemented "blind storage" logic - backend stores opaque blobs and does not attempt decryption.
|
||||||
|
|
||||||
|
## 3. Deployment Status (Pending)
|
||||||
|
- [x] **Compilation**: Successfully compiled `sojorn-api-linux` and `migrate-linux` binaries locally.
|
||||||
|
- [ ] **Upload**: Failed to upload binaries to VPS (`194.238.28.122`) due to SSH authentication failure ("Permission denied") with provided credentials.
|
||||||
|
- [ ] **Migration**: Database migration failed from local machine due to port 5432 being closed/filtered. Needs to be run from the VPS.
|
||||||
|
- [ ] **Restart**: Service restart pending successful SSH access.
|
||||||
|
|
||||||
|
## 4. Next Steps
|
||||||
|
Once SSH access is restored (verify password or add public key):
|
||||||
|
1. **Upload Binaries**: `scp sojorn-api-linux migrate-linux root@194.238.28.122:/root/`
|
||||||
|
2. **Run Migration**: `ssh root@... "./migrate-linux -path ... up"`
|
||||||
|
3. **Restart Service**: `ssh root@... "systemctl restart sojorn-api"`
|
||||||
12
deploy-beacon-function.bat
Normal file
12
deploy-beacon-function.bat
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
@echo off
|
||||||
|
echo Deploying create-beacon edge function to Supabase...
|
||||||
|
echo.
|
||||||
|
|
||||||
|
supabase functions deploy create-beacon --no-verify-jwt
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Deployment complete!
|
||||||
|
echo.
|
||||||
|
echo The beacon feature should now work properly.
|
||||||
|
echo Test by opening the Beacon tab in the app and creating a beacon.
|
||||||
|
pause
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
# sojorn.net - Pure redirect to mp.ls
|
|
||||||
server {
|
|
||||||
server_name sojorn.net www.sojorn.net;
|
|
||||||
|
|
||||||
location = /terms {
|
|
||||||
return 301 https://mp.ls/terms;
|
|
||||||
}
|
|
||||||
location = /privacy {
|
|
||||||
return 301 https://mp.ls/privacy;
|
|
||||||
}
|
|
||||||
location / {
|
|
||||||
return 301 https://mp.ls/sojorn;
|
|
||||||
}
|
|
||||||
|
|
||||||
listen 443 ssl;
|
|
||||||
ssl_certificate /etc/letsencrypt/live/sojorn.net/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/letsencrypt/live/sojorn.net/privkey.pem;
|
|
||||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
|
||||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
|
||||||
}
|
|
||||||
|
|
||||||
# api.sojorn.net - Backend
|
|
||||||
server {
|
|
||||||
server_name api.sojorn.net;
|
|
||||||
client_max_body_size 100M;
|
|
||||||
|
|
||||||
# Auth endpoints - strict rate limit (5 req/min)
|
|
||||||
location ~ ^/api/v1/(auth|login|register|verify|refresh) {
|
|
||||||
limit_req zone=auth burst=3 nodelay;
|
|
||||||
proxy_pass http://localhost:8080;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Upload endpoints - moderate rate limit (10 req/min)
|
|
||||||
location ~ ^/api/v1/(media|upload) {
|
|
||||||
limit_req zone=upload burst=5 nodelay;
|
|
||||||
proxy_pass http://localhost:8080;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
# All other API endpoints - general rate limit (30 req/s)
|
|
||||||
location / {
|
|
||||||
limit_req zone=api burst=50 nodelay;
|
|
||||||
proxy_pass http://localhost:8080;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
|
|
||||||
# WebSocket support
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
}
|
|
||||||
|
|
||||||
listen 443 ssl;
|
|
||||||
ssl_certificate /etc/letsencrypt/live/sojorn.net/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/letsencrypt/live/sojorn.net/privkey.pem;
|
|
||||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
|
||||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
if ($host = www.sojorn.net) {
|
|
||||||
return 301 https://$host$request_uri;
|
|
||||||
}
|
|
||||||
if ($host = sojorn.net) {
|
|
||||||
return 301 https://$host$request_uri;
|
|
||||||
}
|
|
||||||
server_name sojorn.net www.sojorn.net;
|
|
||||||
listen 80;
|
|
||||||
return 404;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
if ($host = api.sojorn.net) {
|
|
||||||
return 301 https://$host$request_uri;
|
|
||||||
}
|
|
||||||
server_name api.sojorn.net;
|
|
||||||
listen 80;
|
|
||||||
return 404;
|
|
||||||
}
|
|
||||||
62
deploy_all_functions.ps1
Normal file
62
deploy_all_functions.ps1
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# Deploy all Edge Functions to Supabase
|
||||||
|
# Run this after updating supabase-js version
|
||||||
|
|
||||||
|
Write-Host "=== Deploying All Edge Functions ===" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "This will deploy all functions with --no-verify-jwt (default for this script)" -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
$functions = @(
|
||||||
|
"appreciate",
|
||||||
|
"block",
|
||||||
|
"deactivate-account",
|
||||||
|
"delete-account",
|
||||||
|
"feed-personal",
|
||||||
|
"feed-sojorn",
|
||||||
|
"follow",
|
||||||
|
"manage-post",
|
||||||
|
"notifications",
|
||||||
|
"profile",
|
||||||
|
"profile-posts",
|
||||||
|
"publish-comment",
|
||||||
|
"publish-post",
|
||||||
|
"push-notification",
|
||||||
|
"report",
|
||||||
|
"save",
|
||||||
|
"search",
|
||||||
|
"sign-media",
|
||||||
|
"signup",
|
||||||
|
"tone-check",
|
||||||
|
"trending",
|
||||||
|
"upload-image"
|
||||||
|
)
|
||||||
|
|
||||||
|
$totalFunctions = $functions.Count
|
||||||
|
$currentFunction = 0
|
||||||
|
$noVerifyJwt = "--no-verify-jwt"
|
||||||
|
|
||||||
|
foreach ($func in $functions) {
|
||||||
|
$currentFunction++
|
||||||
|
Write-Host "[$currentFunction/$totalFunctions] Deploying $func..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
try {
|
||||||
|
supabase functions deploy $func $noVerifyJwt 2>&1 | Out-Null
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
Write-Host " OK $func deployed successfully" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host " FAILED to deploy $func" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host " ERROR deploying $func : $_" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "=== Deployment Complete ===" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Next steps:" -ForegroundColor Yellow
|
||||||
|
Write-Host "1. Restart your Flutter app" -ForegroundColor Yellow
|
||||||
|
Write-Host "2. Sign in again" -ForegroundColor Yellow
|
||||||
|
Write-Host "3. The JWT 401 errors should be gone!" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
35
feed_reactions_fix.md
Normal file
35
feed_reactions_fix.md
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
# Feed Reactions Fix - Implementation Summary
|
||||||
|
|
||||||
|
## Status: COMPLETE & DEPLOYED ✅
|
||||||
|
|
||||||
|
### 1. Issue Description
|
||||||
|
- **Problem**: Reactions were not displaying on the "Following" (Home) and "Profile" feeds. The "Postcard" UI (specifically the compact method) was defaulting to the "Add Reaction" button because no reaction data was being returned by the API for these lists.
|
||||||
|
- **Root Cause**: The SQL queries for `GetFeed` and `GetPostsByAuthor` in the backend were not aggregating reaction data (counts and user choices), unlike the single-post endpoint.
|
||||||
|
|
||||||
|
### 2. Implementation Details (Backend)
|
||||||
|
- **File**: `internal/repository/post_repository.go`
|
||||||
|
- **Changes**:
|
||||||
|
- Modified `GetFeed` SQL query to include a correlated subquery fetching `jsonb_object_agg(emoji, count)` for `reaction_counts`.
|
||||||
|
- Modified `GetPostsByAuthor` SQL query to do the same.
|
||||||
|
- Added logic to fetch `my_reactions` (the current user's votes) for both feeds.
|
||||||
|
- Updated the Go `Scan` destinations to populate the `Reactions` and `MyReactions` fields in the `Post` model.
|
||||||
|
- **Correction**: Also fixed a missing `allow_chain` and `visibility` selection in the `GetPostsByAuthor` query, ensuring consistency across the app.
|
||||||
|
|
||||||
|
### 3. Frontend logic (Verified)
|
||||||
|
- **Widget**: `sojornPostCard` -> `PostActions` -> `ReactionsDisplay`
|
||||||
|
- **Logic**: The `ReactionsDisplay` widget in `compact` mode (used in feeds) is designed to:
|
||||||
|
1. Show the user's reaction if they voted.
|
||||||
|
2. Else, show the "Most Used" reaction from the community.
|
||||||
|
3. Else (if valid data but count is 0), show the "Add" button.
|
||||||
|
- **Result**: Now that the backend provides the data (Case 1 or 2), the UI will correctly display the reaction chips instead of just the "+" button.
|
||||||
|
|
||||||
|
### 4. Deployment
|
||||||
|
- **Server**: `194.238.28.122`
|
||||||
|
- **Action**:
|
||||||
|
- Compiled `sojorn-api-linux` locally.
|
||||||
|
- Uploaded to `/opt/sojorn/bin/api` via `scp` (user: `patrick`).
|
||||||
|
- Restarted `sojorn-api` service via `systemctl`.
|
||||||
|
- **Status**: API is active and serving the new queries.
|
||||||
|
|
||||||
|
### 5. Next Steps
|
||||||
|
- **User Action**: Pull-to-refresh the feed in the Sojorn app to see the changes.
|
||||||
45
fix_fcm_and_restart.sh
Normal file
45
fix_fcm_and_restart.sh
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Fix FCM configuration and restart backend
|
||||||
|
# Run with: bash fix_fcm_and_restart.sh
|
||||||
|
|
||||||
|
echo "=== Fixing FCM Configuration ==="
|
||||||
|
|
||||||
|
# Kill old backend process
|
||||||
|
echo "Killing old backend process on port 8080..."
|
||||||
|
sudo kill -9 $(sudo lsof -ti:8080) 2>/dev/null || echo "No process to kill"
|
||||||
|
|
||||||
|
# Verify Firebase JSON exists
|
||||||
|
if [ -f "/opt/sojorn/firebase-service-account.json" ]; then
|
||||||
|
echo "✓ Firebase service account JSON exists"
|
||||||
|
ls -lh /opt/sojorn/firebase-service-account.json
|
||||||
|
else
|
||||||
|
echo "✗ Firebase service account JSON not found!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Add FIREBASE_CREDENTIALS_FILE to .env if not present
|
||||||
|
if ! grep -q "FIREBASE_CREDENTIALS_FILE" /opt/sojorn/.env; then
|
||||||
|
echo "Adding FIREBASE_CREDENTIALS_FILE to .env..."
|
||||||
|
echo "" | sudo tee -a /opt/sojorn/.env > /dev/null
|
||||||
|
echo "FIREBASE_CREDENTIALS_FILE=/opt/sojorn/firebase-service-account.json" | sudo tee -a /opt/sojorn/.env > /dev/null
|
||||||
|
echo "✓ Added FIREBASE_CREDENTIALS_FILE"
|
||||||
|
else
|
||||||
|
echo "✓ FIREBASE_CREDENTIALS_FILE already in .env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Restart backend
|
||||||
|
echo ""
|
||||||
|
echo "=== Restarting Backend ==="
|
||||||
|
sudo systemctl restart sojorn-api
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
sudo systemctl status sojorn-api --no-pager | head -20
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Checking FCM Initialization ==="
|
||||||
|
sudo journalctl -u sojorn-api --since "30 seconds ago" | grep -i "push\|fcm\|firebase" || echo "No FCM logs yet"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Done! ==="
|
||||||
|
echo "If you see 'Server started on port 8080' with no errors, FCM is working!"
|
||||||
BIN
go-backend/api
BIN
go-backend/api
Binary file not shown.
|
|
@ -16,12 +16,12 @@ import (
|
||||||
"github.com/gin-contrib/cors"
|
"github.com/gin-contrib/cors"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/config"
|
"github.com/patbritton/sojorn-backend/internal/config"
|
||||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/handlers"
|
"github.com/patbritton/sojorn-backend/internal/handlers"
|
||||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/middleware"
|
"github.com/patbritton/sojorn-backend/internal/middleware"
|
||||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/realtime"
|
"github.com/patbritton/sojorn-backend/internal/realtime"
|
||||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/repository"
|
"github.com/patbritton/sojorn-backend/internal/repository"
|
||||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/services"
|
"github.com/patbritton/sojorn-backend/internal/services"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
@ -111,12 +111,12 @@ func main() {
|
||||||
|
|
||||||
notificationService := services.NewNotificationService(notifRepo, pushService, userRepo)
|
notificationService := services.NewNotificationService(notifRepo, pushService, userRepo)
|
||||||
|
|
||||||
emailService := services.NewEmailService(cfg, dbPool)
|
emailService := services.NewEmailService(cfg)
|
||||||
sendPulseService := services.NewSendPulseService(cfg.SendPulseID, cfg.SendPulseSecret)
|
sendPulseService := services.NewSendPulseService(cfg.SendPulseID, cfg.SendPulseSecret)
|
||||||
|
|
||||||
// Load moderation configuration
|
// Load moderation configuration
|
||||||
moderationConfig := config.NewModerationConfig()
|
moderationConfig := config.NewModerationConfig()
|
||||||
moderationService := services.NewModerationService(dbPool, moderationConfig.OpenAIKey, moderationConfig.GoogleKey, moderationConfig.GoogleCredsFile)
|
moderationService := services.NewModerationService(dbPool, moderationConfig.OpenAIKey, moderationConfig.GoogleKey)
|
||||||
|
|
||||||
// Initialize appeal service
|
// Initialize appeal service
|
||||||
appealService := services.NewAppealService(dbPool)
|
appealService := services.NewAppealService(dbPool)
|
||||||
|
|
@ -124,26 +124,9 @@ func main() {
|
||||||
// Initialize OpenRouter service
|
// Initialize OpenRouter service
|
||||||
openRouterService := services.NewOpenRouterService(dbPool, cfg.OpenRouterAPIKey)
|
openRouterService := services.NewOpenRouterService(dbPool, cfg.OpenRouterAPIKey)
|
||||||
|
|
||||||
// Initialize Azure OpenAI service
|
|
||||||
var azureOpenAIService *services.AzureOpenAIService
|
|
||||||
if cfg.AzureOpenAIAPIKey != "" && cfg.AzureOpenAIEndpoint != "" {
|
|
||||||
azureOpenAIService = services.NewAzureOpenAIService(dbPool, cfg.AzureOpenAIAPIKey, cfg.AzureOpenAIEndpoint, cfg.AzureOpenAIAPIVersion)
|
|
||||||
log.Info().Msg("Azure OpenAI service initialized")
|
|
||||||
} else {
|
|
||||||
log.Warn().Msg("Azure OpenAI credentials not provided, Azure OpenAI service disabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize content filter (hard blocklist + strike system)
|
// Initialize content filter (hard blocklist + strike system)
|
||||||
contentFilter := services.NewContentFilter(dbPool)
|
contentFilter := services.NewContentFilter(dbPool)
|
||||||
|
|
||||||
// Initialize local AI gateway service (on-server Ollama via localhost:8099)
|
|
||||||
localAIService := services.NewLocalAIService(cfg.AIGatewayURL, cfg.AIGatewayToken)
|
|
||||||
if localAIService != nil {
|
|
||||||
log.Info().Str("url", cfg.AIGatewayURL).Msg("Local AI gateway configured")
|
|
||||||
} else {
|
|
||||||
log.Info().Msg("Local AI gateway not configured (AI_GATEWAY_URL not set)")
|
|
||||||
}
|
|
||||||
|
|
||||||
hub := realtime.NewHub()
|
hub := realtime.NewHub()
|
||||||
wsHandler := handlers.NewWSHandler(hub, cfg.JWTSecret)
|
wsHandler := handlers.NewWSHandler(hub, cfg.JWTSecret)
|
||||||
|
|
||||||
|
|
@ -169,7 +152,7 @@ func main() {
|
||||||
linkPreviewService := services.NewLinkPreviewService(dbPool, s3Client, cfg.R2MediaBucket, cfg.R2ImgDomain)
|
linkPreviewService := services.NewLinkPreviewService(dbPool, s3Client, cfg.R2MediaBucket, cfg.R2ImgDomain)
|
||||||
|
|
||||||
userHandler := handlers.NewUserHandler(userRepo, postRepo, notificationService, assetService)
|
userHandler := handlers.NewUserHandler(userRepo, postRepo, notificationService, assetService)
|
||||||
postHandler := handlers.NewPostHandler(postRepo, userRepo, feedService, assetService, notificationService, moderationService, contentFilter, openRouterService, linkPreviewService, localAIService)
|
postHandler := handlers.NewPostHandler(postRepo, userRepo, feedService, assetService, notificationService, moderationService, contentFilter, openRouterService, linkPreviewService)
|
||||||
chatHandler := handlers.NewChatHandler(chatRepo, notificationService, hub)
|
chatHandler := handlers.NewChatHandler(chatRepo, notificationService, hub)
|
||||||
authHandler := handlers.NewAuthHandler(userRepo, cfg, emailService, sendPulseService)
|
authHandler := handlers.NewAuthHandler(userRepo, cfg, emailService, sendPulseService)
|
||||||
categoryHandler := handlers.NewCategoryHandler(categoryRepo)
|
categoryHandler := handlers.NewCategoryHandler(categoryRepo)
|
||||||
|
|
@ -180,29 +163,14 @@ func main() {
|
||||||
appealHandler := handlers.NewAppealHandler(appealService)
|
appealHandler := handlers.NewAppealHandler(appealService)
|
||||||
|
|
||||||
// Initialize official accounts service
|
// Initialize official accounts service
|
||||||
officialAccountsService := services.NewOfficialAccountsService(dbPool, openRouterService, localAIService, linkPreviewService, moderationConfig.OpenAIKey)
|
officialAccountsService := services.NewOfficialAccountsService(dbPool, openRouterService, linkPreviewService)
|
||||||
officialAccountsService.StartScheduler()
|
officialAccountsService.StartScheduler()
|
||||||
defer officialAccountsService.StopScheduler()
|
defer officialAccountsService.StopScheduler()
|
||||||
|
|
||||||
moderationHandler := handlers.NewModerationHandler(moderationService, openRouterService, localAIService)
|
adminHandler := handlers.NewAdminHandler(dbPool, moderationService, appealService, emailService, openRouterService, officialAccountsService, linkPreviewService, cfg.JWTSecret, cfg.TurnstileSecretKey, s3Client, cfg.R2MediaBucket, cfg.R2VideoBucket, cfg.R2ImgDomain, cfg.R2VidDomain)
|
||||||
|
|
||||||
adminHandler := handlers.NewAdminHandler(dbPool, moderationService, appealService, emailService, openRouterService, azureOpenAIService, officialAccountsService, linkPreviewService, localAIService, cfg.JWTSecret, cfg.TurnstileSecretKey, s3Client, cfg.R2MediaBucket, cfg.R2VideoBucket, cfg.R2ImgDomain, cfg.R2VidDomain)
|
|
||||||
|
|
||||||
accountHandler := handlers.NewAccountHandler(userRepo, emailService, cfg)
|
accountHandler := handlers.NewAccountHandler(userRepo, emailService, cfg)
|
||||||
|
|
||||||
// Capsule system handlers (E2EE groups)
|
|
||||||
capsuleHandler := handlers.NewCapsuleHandler(dbPool)
|
|
||||||
capsuleEscrowHandler := handlers.NewCapsuleEscrowHandler(dbPool)
|
|
||||||
|
|
||||||
// Group feature handler (posts, chat, forum, members)
|
|
||||||
groupHandler := handlers.NewGroupHandler(dbPool)
|
|
||||||
|
|
||||||
// Neighborhood board handler (standalone message board)
|
|
||||||
boardHandler := handlers.NewBoardHandler(dbPool, contentFilter, moderationService)
|
|
||||||
|
|
||||||
// Beacon search handler (search beacons, board, public groups)
|
|
||||||
beaconSearchHandler := handlers.NewBeaconSearchHandler(dbPool)
|
|
||||||
|
|
||||||
mediaHandler := handlers.NewMediaHandler(
|
mediaHandler := handlers.NewMediaHandler(
|
||||||
s3Client,
|
s3Client,
|
||||||
cfg.R2AccountID,
|
cfg.R2AccountID,
|
||||||
|
|
@ -222,13 +190,6 @@ func main() {
|
||||||
c.Status(200)
|
c.Status(200)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ALTCHA challenge endpoints (direct to main router for testing)
|
|
||||||
r.GET("/api/v1/auth/altcha-challenge", authHandler.GetAltchaChallenge)
|
|
||||||
r.GET("/api/v1/admin/altcha-challenge", adminHandler.GetAltchaChallenge)
|
|
||||||
r.GET("/api/v1/test", func(c *gin.Context) {
|
|
||||||
c.JSON(200, gin.H{"message": "Route test successful"})
|
|
||||||
})
|
|
||||||
|
|
||||||
v1 := r.Group("/api/v1")
|
v1 := r.Group("/api/v1")
|
||||||
{
|
{
|
||||||
// Public waitlist signup (no auth required)
|
// Public waitlist signup (no auth required)
|
||||||
|
|
@ -257,6 +218,7 @@ func main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
auth := v1.Group("/auth")
|
auth := v1.Group("/auth")
|
||||||
|
auth.Use(middleware.RateLimit(0.5, 3))
|
||||||
{
|
{
|
||||||
auth.POST("/register", authHandler.Register)
|
auth.POST("/register", authHandler.Register)
|
||||||
auth.POST("/signup", authHandler.Register)
|
auth.POST("/signup", authHandler.Register)
|
||||||
|
|
@ -332,7 +294,6 @@ func main() {
|
||||||
authorized.POST("/posts/:id/reactions/toggle", postHandler.ToggleReaction)
|
authorized.POST("/posts/:id/reactions/toggle", postHandler.ToggleReaction)
|
||||||
authorized.POST("/posts/:id/comments", postHandler.CreateComment)
|
authorized.POST("/posts/:id/comments", postHandler.CreateComment)
|
||||||
authorized.GET("/feed", postHandler.GetFeed)
|
authorized.GET("/feed", postHandler.GetFeed)
|
||||||
authorized.POST("/beacons", postHandler.CreateBeacon)
|
|
||||||
authorized.GET("/beacons/nearby", postHandler.GetNearbyBeacons)
|
authorized.GET("/beacons/nearby", postHandler.GetNearbyBeacons)
|
||||||
authorized.POST("/beacons/:id/vouch", postHandler.VouchBeacon)
|
authorized.POST("/beacons/:id/vouch", postHandler.VouchBeacon)
|
||||||
authorized.POST("/beacons/:id/report", postHandler.ReportBeacon)
|
authorized.POST("/beacons/:id/report", postHandler.ReportBeacon)
|
||||||
|
|
@ -341,7 +302,6 @@ func main() {
|
||||||
authorized.POST("/categories/settings", categoryHandler.SetUserCategorySettings)
|
authorized.POST("/categories/settings", categoryHandler.SetUserCategorySettings)
|
||||||
authorized.GET("/categories/settings", categoryHandler.GetUserCategorySettings)
|
authorized.GET("/categories/settings", categoryHandler.GetUserCategorySettings)
|
||||||
authorized.POST("/analysis/tone", analysisHandler.CheckTone)
|
authorized.POST("/analysis/tone", analysisHandler.CheckTone)
|
||||||
authorized.POST("/moderate", moderationHandler.CheckContent)
|
|
||||||
|
|
||||||
// Chat routes
|
// Chat routes
|
||||||
authorized.GET("/conversations", chatHandler.GetConversations)
|
authorized.GET("/conversations", chatHandler.GetConversations)
|
||||||
|
|
@ -434,85 +394,6 @@ func main() {
|
||||||
appeals.GET("/:id", appealHandler.GetAppeal)
|
appeals.GET("/:id", appealHandler.GetAppeal)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Neighborhood board (standalone message board — NOT posts)
|
|
||||||
board := authorized.Group("/board")
|
|
||||||
{
|
|
||||||
board.GET("/nearby", boardHandler.ListNearby)
|
|
||||||
board.POST("", boardHandler.CreateEntry)
|
|
||||||
board.GET("/:id", boardHandler.GetEntry)
|
|
||||||
board.POST("/:id/replies", boardHandler.CreateReply)
|
|
||||||
board.POST("/vote", boardHandler.ToggleVote)
|
|
||||||
board.POST("/:id/remove", boardHandler.RemoveEntry)
|
|
||||||
board.POST("/:id/flag", boardHandler.FlagEntry)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Beacon ecosystem search (beacons, board entries, public groups — never private)
|
|
||||||
authorized.GET("/beacon/search", beaconSearchHandler.Search)
|
|
||||||
|
|
||||||
// Neighborhood system (on-demand OSM detection + auto-join)
|
|
||||||
neighborhoodHandler := handlers.NewNeighborhoodHandler(dbPool)
|
|
||||||
neighborhoods := authorized.Group("/neighborhoods")
|
|
||||||
{
|
|
||||||
neighborhoods.GET("/detect", neighborhoodHandler.Detect)
|
|
||||||
neighborhoods.GET("/current", neighborhoodHandler.GetCurrent)
|
|
||||||
neighborhoods.GET("/search", neighborhoodHandler.SearchByZip)
|
|
||||||
neighborhoods.POST("/choose", neighborhoodHandler.Choose)
|
|
||||||
neighborhoods.GET("/mine", neighborhoodHandler.GetMyNeighborhood)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capsule system (E2EE groups + clusters)
|
|
||||||
capsules := authorized.Group("/capsules")
|
|
||||||
{
|
|
||||||
capsules.GET("/mine", capsuleHandler.ListMyGroups)
|
|
||||||
capsules.GET("/public", capsuleHandler.ListPublicClusters)
|
|
||||||
capsules.POST("", capsuleHandler.CreateCapsule)
|
|
||||||
capsules.POST("/group", capsuleHandler.CreateGroup)
|
|
||||||
capsules.GET("/:id", capsuleHandler.GetCapsule)
|
|
||||||
capsules.POST("/:id/entries", capsuleHandler.PostCapsuleEntry)
|
|
||||||
capsules.GET("/:id/entries", capsuleHandler.GetCapsuleEntries)
|
|
||||||
capsules.POST("/:id/invite", capsuleHandler.InviteToCapsule)
|
|
||||||
capsules.POST("/:id/rotate-keys", capsuleHandler.RotateKeys)
|
|
||||||
|
|
||||||
// Group features (posts, chat, forum, members)
|
|
||||||
capsules.GET("/:id/posts", groupHandler.ListGroupPosts)
|
|
||||||
capsules.POST("/:id/posts", groupHandler.CreateGroupPost)
|
|
||||||
capsules.POST("/:id/posts/:postId/like", groupHandler.ToggleGroupPostLike)
|
|
||||||
capsules.GET("/:id/posts/:postId/comments", groupHandler.ListGroupPostComments)
|
|
||||||
capsules.POST("/:id/posts/:postId/comments", groupHandler.CreateGroupPostComment)
|
|
||||||
capsules.GET("/:id/messages", groupHandler.ListGroupMessages)
|
|
||||||
capsules.POST("/:id/messages", groupHandler.SendGroupMessage)
|
|
||||||
capsules.GET("/:id/threads", groupHandler.ListGroupThreads)
|
|
||||||
capsules.POST("/:id/threads", groupHandler.CreateGroupThread)
|
|
||||||
capsules.GET("/:id/threads/:threadId", groupHandler.GetGroupThread)
|
|
||||||
capsules.POST("/:id/threads/:threadId/replies", groupHandler.CreateGroupThreadReply)
|
|
||||||
capsules.GET("/:id/members", groupHandler.ListGroupMembers)
|
|
||||||
capsules.DELETE("/:id/members/:memberId", groupHandler.RemoveGroupMember)
|
|
||||||
capsules.PATCH("/:id/members/:memberId", groupHandler.UpdateMemberRole)
|
|
||||||
capsules.POST("/:id/leave", groupHandler.LeaveGroup)
|
|
||||||
capsules.PATCH("/:id", groupHandler.UpdateGroup)
|
|
||||||
capsules.DELETE("/:id", groupHandler.DeleteGroup)
|
|
||||||
capsules.POST("/:id/invite-member", groupHandler.InviteToGroup)
|
|
||||||
capsules.GET("/:id/search-users", groupHandler.SearchUsersForInvite)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capsule key management (per-user encrypted key store)
|
|
||||||
capsuleKeys := authorized.Group("/capsule-keys")
|
|
||||||
{
|
|
||||||
capsuleKeys.GET("", capsuleEscrowHandler.GetMyKeys)
|
|
||||||
capsuleKeys.POST("", capsuleEscrowHandler.StoreKey)
|
|
||||||
capsuleKeys.GET("/:id", capsuleEscrowHandler.GetMyKeyForGroup)
|
|
||||||
capsuleKeys.DELETE("/:id", capsuleEscrowHandler.DeleteKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capsule escrow backup (PIN-encrypted private key recovery)
|
|
||||||
escrow := authorized.Group("/capsule/escrow")
|
|
||||||
{
|
|
||||||
escrow.GET("/status", capsuleEscrowHandler.GetBackupStatus)
|
|
||||||
escrow.POST("/backup", capsuleEscrowHandler.UploadBackup)
|
|
||||||
escrow.GET("/backup", capsuleEscrowHandler.GetBackup)
|
|
||||||
escrow.DELETE("/backup", capsuleEscrowHandler.DeleteBackup)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -574,13 +455,6 @@ func main() {
|
||||||
admin.POST("/categories", adminHandler.CreateCategory)
|
admin.POST("/categories", adminHandler.CreateCategory)
|
||||||
admin.PATCH("/categories/:id", adminHandler.UpdateCategory)
|
admin.PATCH("/categories/:id", adminHandler.UpdateCategory)
|
||||||
|
|
||||||
// Neighborhoods
|
|
||||||
admin.GET("/neighborhoods", adminHandler.ListNeighborhoods)
|
|
||||||
admin.POST("/neighborhoods/:id/admins", adminHandler.SetNeighborhoodAdmin)
|
|
||||||
admin.GET("/neighborhoods/:id/admins", adminHandler.ListNeighborhoodAdmins)
|
|
||||||
admin.GET("/neighborhoods/:id/board", adminHandler.ListNeighborhoodBoardEntries)
|
|
||||||
admin.PATCH("/neighborhoods/:id/board/:entryId", adminHandler.UpdateNeighborhoodBoardEntry)
|
|
||||||
|
|
||||||
// System
|
// System
|
||||||
admin.GET("/health", adminHandler.GetSystemHealth)
|
admin.GET("/health", adminHandler.GetSystemHealth)
|
||||||
admin.GET("/audit-log", adminHandler.GetAuditLog)
|
admin.GET("/audit-log", adminHandler.GetAuditLog)
|
||||||
|
|
@ -603,7 +477,6 @@ func main() {
|
||||||
|
|
||||||
// AI Moderation Config
|
// AI Moderation Config
|
||||||
admin.GET("/ai/models", adminHandler.ListOpenRouterModels)
|
admin.GET("/ai/models", adminHandler.ListOpenRouterModels)
|
||||||
admin.GET("/ai/models/local", adminHandler.ListLocalModels)
|
|
||||||
admin.GET("/ai/config", adminHandler.GetAIModerationConfigs)
|
admin.GET("/ai/config", adminHandler.GetAIModerationConfigs)
|
||||||
admin.PUT("/ai/config", adminHandler.SetAIModerationConfig)
|
admin.PUT("/ai/config", adminHandler.SetAIModerationConfig)
|
||||||
admin.POST("/ai/test", adminHandler.TestAIModeration)
|
admin.POST("/ai/test", adminHandler.TestAIModeration)
|
||||||
|
|
@ -628,26 +501,12 @@ func main() {
|
||||||
admin.POST("/official-accounts/:id/preview", adminHandler.PreviewOfficialPost)
|
admin.POST("/official-accounts/:id/preview", adminHandler.PreviewOfficialPost)
|
||||||
admin.GET("/official-accounts/:id/articles", adminHandler.FetchNewsArticles)
|
admin.GET("/official-accounts/:id/articles", adminHandler.FetchNewsArticles)
|
||||||
admin.GET("/official-accounts/:id/posted", adminHandler.GetPostedArticles)
|
admin.GET("/official-accounts/:id/posted", adminHandler.GetPostedArticles)
|
||||||
admin.POST("/official-accounts/:id/articles/cleanup", adminHandler.CleanupPendingArticles)
|
|
||||||
admin.POST("/official-accounts/articles/:article_id/skip", adminHandler.SkipArticle)
|
|
||||||
admin.POST("/official-accounts/articles/:article_id/post", adminHandler.PostSpecificArticle)
|
|
||||||
admin.DELETE("/official-accounts/articles/:article_id", adminHandler.DeleteArticle)
|
|
||||||
|
|
||||||
// AI Engines Status
|
|
||||||
admin.GET("/ai-engines", adminHandler.GetAIEngines)
|
|
||||||
admin.POST("/upload-test-image", adminHandler.UploadTestImage)
|
|
||||||
|
|
||||||
// Safe Domains Management
|
// Safe Domains Management
|
||||||
admin.GET("/safe-domains", adminHandler.ListSafeDomains)
|
admin.GET("/safe-domains", adminHandler.ListSafeDomains)
|
||||||
admin.POST("/safe-domains", adminHandler.UpsertSafeDomain)
|
admin.POST("/safe-domains", adminHandler.UpsertSafeDomain)
|
||||||
admin.DELETE("/safe-domains/:id", adminHandler.DeleteSafeDomain)
|
admin.DELETE("/safe-domains/:id", adminHandler.DeleteSafeDomain)
|
||||||
admin.GET("/safe-domains/check", adminHandler.CheckURLSafety)
|
admin.GET("/safe-domains/check", adminHandler.CheckURLSafety)
|
||||||
|
|
||||||
// Email Templates
|
|
||||||
admin.GET("/email-templates", adminHandler.ListEmailTemplates)
|
|
||||||
admin.GET("/email-templates/:id", adminHandler.GetEmailTemplate)
|
|
||||||
admin.PATCH("/email-templates/:id", adminHandler.UpdateEmailTemplate)
|
|
||||||
admin.POST("/email-templates/test", adminHandler.SendTestEmail)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public claim request endpoint (no auth)
|
// Public claim request endpoint (no auth)
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/config"
|
"github.com/patbritton/sojorn-backend/internal/config"
|
||||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/database"
|
"github.com/patbritton/sojorn-backend/internal/database"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,11 @@ func main() {
|
||||||
// Try to load .env, but don't fail if missing (env vars might be set manually)
|
// Try to load .env, but don't fail if missing (env vars might be set manually)
|
||||||
_ = godotenv.Load("../../.env")
|
_ = godotenv.Load("../../.env")
|
||||||
|
|
||||||
// Get DB URL from env
|
// Get DB URL from env or use the hardcoded one we saw in logs
|
||||||
connStr := os.Getenv("DATABASE_URL")
|
connStr := os.Getenv("DATABASE_URL")
|
||||||
if connStr == "" {
|
if connStr == "" {
|
||||||
log.Fatal("DATABASE_URL is required")
|
// Fallback to the known connection string from your .env
|
||||||
|
connStr = "postgres://postgres:A24Zr7AEoch4eO0N@localhost:5432/postgres?sslmode=disable"
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Connecting to DB...")
|
fmt.Println("Connecting to DB...")
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/config"
|
"github.com/patbritton/sojorn-backend/internal/config"
|
||||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/database"
|
"github.com/patbritton/sojorn-backend/internal/database"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/config"
|
"github.com/patbritton/sojorn-backend/internal/config"
|
||||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/database"
|
"github.com/patbritton/sojorn-backend/internal/database"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/config"
|
"github.com/patbritton/sojorn-backend/internal/config"
|
||||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/database"
|
"github.com/patbritton/sojorn-backend/internal/database"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ import (
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/models"
|
"github.com/patbritton/sojorn-backend/internal/models"
|
||||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/repository"
|
"github.com/patbritton/sojorn-backend/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
|
||||||
26
go-backend/directus-docker-compose.yml
Normal file
26
go-backend/directus-docker-compose.yml
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
version: '3'
|
||||||
|
services:
|
||||||
|
directus:
|
||||||
|
image: directus/directus:latest
|
||||||
|
ports:
|
||||||
|
- 8055:8055
|
||||||
|
volumes:
|
||||||
|
- ./uploads:/directus/uploads
|
||||||
|
- ./extensions:/directus/extensions
|
||||||
|
network_mode: "host"
|
||||||
|
environment:
|
||||||
|
KEY: "sj_auth_key_replace_me_securely"
|
||||||
|
SECRET: "sj_auth_secret_replace_me_securely"
|
||||||
|
|
||||||
|
# Connect directly to the main 'postgres' database (Sojorn's DB)
|
||||||
|
DB_CLIENT: "pg"
|
||||||
|
DB_HOST: "127.0.0.1"
|
||||||
|
DB_PORT: "5432"
|
||||||
|
DB_DATABASE: "postgres"
|
||||||
|
DB_USER: "postgres"
|
||||||
|
DB_PASSWORD: "A24Zr7AEoch4eO0N"
|
||||||
|
|
||||||
|
ADMIN_EMAIL: "admin@sojorn.com"
|
||||||
|
ADMIN_PASSWORD: "Password123!"
|
||||||
|
|
||||||
|
PUBLIC_URL: "https://sojorn.net/cms"
|
||||||
13
go-backend/firebase-service-account.json
Normal file
13
go-backend/firebase-service-account.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"type": "service_account",
|
||||||
|
"project_id": "britton86-6c1a4",
|
||||||
|
"private_key_id": "e8110c05f9145d3e0f3d426c1243110c100438d9",
|
||||||
|
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCoz+1n/0FxNKSM\nhgS0W6DpltN/QOwEUUBYu/6/aQtnC0oUrdB8ROIgxbLh4e+2RswKRnut7179adqH\nPe9uChldT3mcYStDvcrsmQp1XzrOE9hUxVHM9cwf4Pt6EfPLzGUHJCph1qW9vOVn\n+kDSwyQE0t/zBf2LMDTmQVZTuPoj79BWW+WRwH472useQGh2vcUm2kGqOR+e06c6\nW8yyCNkbVNdrgIj+rb/E2TdvUBGyuJX7A6y/vD7rzcXWxaji/L9w4Iz7+ivUVqyR\nB7sMXCNYam3cejehxzXghIcyhOCizzEkbjk0Hplm7BeG3368C04re35AtLwP93AA\nKocAXX+/AgMBAAECggEABJ9Q5liYb6Ob39cpmNCresns956M999LrkJTiuUy8Trf\nZ+qMe2KdeH1BVSsNF8YffyaH627TAIhd+fcJr88p+6KZ91y3xQ6U2F6maSlW4F6t\nvUB76WtPkA3mhOSp8soheA0W9f/dIPFjrLGvBbZfLZyikjJ3S1DiGe3vEbLOcp5q\nhINV/voDERZCSc4rY4zR2tj0SRZGe3fFrlBjlZwTWapx84PSAmujYadAXXZHe1M9\nQA92WJp+2ZBVv6Q7fF3DUZVt5ONRhVrF4lboiDOyBh6faInzy7a8Rg/DZug773jK\nHTj+ON5kmnZAX3VzV5qhzMuYa9PhfMj4HWdRP3wE8QKBgQDby7Rr3UcLbXNeAUas\nEgB6klGU0lCb0IZJ4I3x7+tK8C/JYJFolP70T3piXX7uWoKcFFlqGaxziUI7QAql\n5OMm5OSuUbP3mb/MwDeywqA2G+b6wKw7aF5yY5OavT9i+PEZnH/kmafI9fXqKMAg\nqu7qBIbIHF5T9xAHlhN5+6XGUQKBgQDEnlymYjsZxgM1gw2uKu7g6wMOju4RkscH\n4uN7Ufadiop31FQgBZZJK4ahnGODaPEPwZ1vOE8R68F+Lk63TGJA+gJdKFqLuZWg\nrGShDyP2rZ7WBNtGqwgvlJ35IV56fuU2aivlNQSMjoM35PWrGC0YjcGJpMIRjS0u\nla6IO2ORDwKBgAbjfYYb60waZBFALPzbm5Q73b6yUMBxaqQKG6jHgjJZEMZY9nW2\npb72drl6gK3rvEg0AxFmOJduZ9r/iNXmNJBVgC1Odjt+YBqEs7owi2Dmwvh87Wj3\nPm6LXGbvI3twne3Vj9SUVEPiIZDzMgJUGSTQe4DuEq7DAYebVoTuNCXhAoGAJS+6\nYDGV8fL0amuF69ns4hcwtdEsj6BOClzMH2fKF9O7Cpza6E+GNAKKbQhx/cmcRhmd\nWAqzUbVgHChP9PT6ZEWkqs/WCDUqaoAQbDG74IzHzLyQaFYyryURl6vK/aoAWgFM\nmgYj/R17Ddg86oYhCVLONuU1WzJzSCtBHjz1QNsCgYEA1gMf9o8QFhvC6nBi1KcG\nIa0GZLECV5gKvA+FjfM1N0T/ZLtxpJPmGS/goSKyddYZXLnskxP4dTHgE9BZs/ZE\n/UCRdGw7fVwuI/hL9CODgGMRpbCFYLcXtGZJeSe0+7fBi14MMrBxtmAmpT0DJuZb\n6G/pBSvSYG6USbv+qOQ/C6g=\n-----END PRIVATE KEY-----\n",
|
||||||
|
"client_email": "firebase-adminsdk-5007c@britton86-6c1a4.iam.gserviceaccount.com",
|
||||||
|
"client_id": "104197666755456354541",
|
||||||
|
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||||
|
"token_uri": "https://oauth2.googleapis.com/token",
|
||||||
|
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||||
|
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-5007c%40britton86-6c1a4.iam.gserviceaccount.com",
|
||||||
|
"universe_domain": "googleapis.com"
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
module gitlab.com/patrickbritton3/sojorn/go-backend
|
module github.com/patbritton/sojorn-backend
|
||||||
|
|
||||||
go 1.25.4
|
go 1.25.4
|
||||||
|
|
||||||
|
|
@ -19,9 +19,7 @@ require (
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
github.com/pashagolub/pgxmock/v4 v4.9.0
|
github.com/pashagolub/pgxmock/v4 v4.9.0
|
||||||
github.com/rs/zerolog v1.34.0
|
github.com/rs/zerolog v1.34.0
|
||||||
github.com/stretchr/testify v1.11.1
|
|
||||||
golang.org/x/crypto v0.47.0
|
golang.org/x/crypto v0.47.0
|
||||||
golang.org/x/oauth2 v0.34.0
|
|
||||||
golang.org/x/time v0.14.0
|
golang.org/x/time v0.14.0
|
||||||
google.golang.org/api v0.262.0
|
google.golang.org/api v0.262.0
|
||||||
)
|
)
|
||||||
|
|
@ -41,6 +39,7 @@ require (
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect
|
||||||
github.com/MicahParks/keyfunc v1.9.0 // indirect
|
github.com/MicahParks/keyfunc v1.9.0 // indirect
|
||||||
|
github.com/NaySoftware/go-fcm v0.0.0-20190516140123-808e978ddcd2 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
|
||||||
|
|
@ -61,7 +60,6 @@ require (
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f // indirect
|
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
|
||||||
github.com/envoyproxy/go-control-plane/envoy v1.35.0 // indirect
|
github.com/envoyproxy/go-control-plane/envoy v1.35.0 // indirect
|
||||||
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
|
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
|
|
@ -90,13 +88,13 @@ require (
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/oschwald/geoip2-golang v1.13.0 // indirect
|
||||||
|
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
|
||||||
github.com/quic-go/qpack v0.5.1 // indirect
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||||
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
||||||
github.com/stretchr/objx v0.5.2 // indirect
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
|
|
@ -112,6 +110,7 @@ require (
|
||||||
golang.org/x/arch v0.20.0 // indirect
|
golang.org/x/arch v0.20.0 // indirect
|
||||||
golang.org/x/mod v0.31.0 // indirect
|
golang.org/x/mod v0.31.0 // indirect
|
||||||
golang.org/x/net v0.49.0 // indirect
|
golang.org/x/net v0.49.0 // indirect
|
||||||
|
golang.org/x/oauth2 v0.34.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
golang.org/x/text v0.33.0 // indirect
|
golang.org/x/text v0.33.0 // indirect
|
||||||
|
|
@ -122,5 +121,4 @@ require (
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120174246-409b4a993575 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120174246-409b4a993575 // indirect
|
||||||
google.golang.org/grpc v1.78.0 // indirect
|
google.golang.org/grpc v1.78.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo=
|
||||||
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
|
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
|
||||||
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
|
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
|
||||||
|
github.com/NaySoftware/go-fcm v0.0.0-20190516140123-808e978ddcd2 h1:0hjpEzUWez7uca/CUBhfidfotTCCI5fsj6Nb+TW5DLg=
|
||||||
|
github.com/NaySoftware/go-fcm v0.0.0-20190516140123-808e978ddcd2/go.mod h1:3qVrdgWvoMZMoRG+/nusrCNrcP4RYU4MWGv467XjqLI=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
|
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
|
||||||
|
|
@ -165,10 +167,6 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/koddr/go-email-sender v0.0.5 h1:cPZxblN5IQdaS6sL8VHZb63C4rbmTCbmBEU15NvZGEo=
|
github.com/koddr/go-email-sender v0.0.5 h1:cPZxblN5IQdaS6sL8VHZb63C4rbmTCbmBEU15NvZGEo=
|
||||||
github.com/koddr/go-email-sender v0.0.5/go.mod h1:2m45qi5oUa+3gMdO0BFTKBx6RHF5y/gNDu6f1dV7mkc=
|
github.com/koddr/go-email-sender v0.0.5/go.mod h1:2m45qi5oUa+3gMdO0BFTKBx6RHF5y/gNDu6f1dV7mkc=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
|
|
@ -184,6 +182,10 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/oschwald/geoip2-golang v1.13.0 h1:Q44/Ldc703pasJeP5V9+aFSZFmBN7DKHbNsSFzQATJI=
|
||||||
|
github.com/oschwald/geoip2-golang v1.13.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo=
|
||||||
|
github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU=
|
||||||
|
github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o=
|
||||||
github.com/pashagolub/pgxmock/v4 v4.9.0 h1:itlO8nrVRnzkdMBXLs8pWUyyB2PC3Gku0WGIj/gGl7I=
|
github.com/pashagolub/pgxmock/v4 v4.9.0 h1:itlO8nrVRnzkdMBXLs8pWUyyB2PC3Gku0WGIj/gGl7I=
|
||||||
github.com/pashagolub/pgxmock/v4 v4.9.0/go.mod h1:9L57pC193h2aKRHVyiiE817avasIPZnPwPlw3JczWvM=
|
github.com/pashagolub/pgxmock/v4 v4.9.0/go.mod h1:9L57pC193h2aKRHVyiiE817avasIPZnPwPlw3JczWvM=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
|
@ -198,8 +200,6 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
|
||||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||||
|
|
@ -208,8 +208,6 @@ github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xI
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
|
@ -310,8 +308,6 @@ google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqw
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|
|
||||||
|
|
@ -39,11 +39,6 @@ type Config struct {
|
||||||
APIBaseURL string
|
APIBaseURL string
|
||||||
AppBaseURL string
|
AppBaseURL string
|
||||||
OpenRouterAPIKey string
|
OpenRouterAPIKey string
|
||||||
AIGatewayURL string
|
|
||||||
AIGatewayToken string
|
|
||||||
AzureOpenAIAPIKey string
|
|
||||||
AzureOpenAIEndpoint string
|
|
||||||
AzureOpenAIAPIVersion string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig() *Config {
|
func LoadConfig() *Config {
|
||||||
|
|
@ -87,13 +82,8 @@ func LoadConfig() *Config {
|
||||||
R2VideoBucket: getEnv("R2_VIDEO_BUCKET", "sojorn-videos"),
|
R2VideoBucket: getEnv("R2_VIDEO_BUCKET", "sojorn-videos"),
|
||||||
TurnstileSecretKey: getEnv("TURNSTILE_SECRET", ""),
|
TurnstileSecretKey: getEnv("TURNSTILE_SECRET", ""),
|
||||||
APIBaseURL: getEnv("API_BASE_URL", "https://api.sojorn.net"),
|
APIBaseURL: getEnv("API_BASE_URL", "https://api.sojorn.net"),
|
||||||
AppBaseURL: getEnv("APP_BASE_URL", "https://mp.ls"),
|
AppBaseURL: getEnv("APP_BASE_URL", "https://sojorn.net"),
|
||||||
OpenRouterAPIKey: getEnv("OPENROUTER_API", ""),
|
OpenRouterAPIKey: getEnv("OPENROUTER_API", ""),
|
||||||
AIGatewayURL: getEnv("AI_GATEWAY_URL", ""),
|
|
||||||
AIGatewayToken: getEnv("AI_GATEWAY_TOKEN", ""),
|
|
||||||
AzureOpenAIAPIKey: getEnv("AZURE_OPENAI_API_KEY", ""),
|
|
||||||
AzureOpenAIEndpoint: getEnv("AZURE_OPENAI_ENDPOINT", ""),
|
|
||||||
AzureOpenAIAPIVersion: getEnv("AZURE_OPENAI_API_VERSION", "2024-02-15-preview"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import (
|
||||||
type ModerationConfig struct {
|
type ModerationConfig struct {
|
||||||
OpenAIKey string
|
OpenAIKey string
|
||||||
GoogleKey string
|
GoogleKey string
|
||||||
GoogleCredsFile string
|
|
||||||
Enabled bool
|
Enabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -17,7 +16,6 @@ func NewModerationConfig() *ModerationConfig {
|
||||||
return &ModerationConfig{
|
return &ModerationConfig{
|
||||||
OpenAIKey: os.Getenv("OPENAI_API_KEY"),
|
OpenAIKey: os.Getenv("OPENAI_API_KEY"),
|
||||||
GoogleKey: os.Getenv("GOOGLE_VISION_API_KEY"),
|
GoogleKey: os.Getenv("GOOGLE_VISION_API_KEY"),
|
||||||
GoogleCredsFile: os.Getenv("GOOGLE_APPLICATION_CREDENTIALS"),
|
|
||||||
Enabled: os.Getenv("MODERATION_ENABLED") != "false",
|
Enabled: os.Getenv("MODERATION_ENABLED") != "false",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -34,5 +32,5 @@ func (c *ModerationConfig) HasOpenAI() bool {
|
||||||
|
|
||||||
// HasGoogleVision returns true if Google Vision API is configured
|
// HasGoogleVision returns true if Google Vision API is configured
|
||||||
func (c *ModerationConfig) HasGoogleVision() bool {
|
func (c *ModerationConfig) HasGoogleVision() bool {
|
||||||
return c.GoogleKey != "" || c.GoogleCredsFile != ""
|
return c.GoogleKey != ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
-- 000001_initial_schema.down.sql
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS reports;
|
||||||
|
DROP TABLE IF EXISTS blocks;
|
||||||
|
DROP TABLE IF EXISTS follows;
|
||||||
|
DROP TABLE IF EXISTS notifications;
|
||||||
|
DROP TABLE IF EXISTS beacon_votes;
|
||||||
|
DROP TABLE IF EXISTS comments;
|
||||||
|
DROP TABLE IF EXISTS post_saves;
|
||||||
|
DROP TABLE IF EXISTS post_likes;
|
||||||
|
DROP TABLE IF EXISTS post_metrics;
|
||||||
|
DROP TABLE IF EXISTS posts;
|
||||||
|
DROP TABLE IF EXISTS categories;
|
||||||
|
DROP TABLE IF EXISTS trust_state;
|
||||||
|
DROP TABLE IF EXISTS profiles;
|
||||||
|
DROP TABLE IF EXISTS users;
|
||||||
|
|
||||||
|
DROP TYPE IF EXISTS post_status;
|
||||||
|
DROP TYPE IF EXISTS tone_label;
|
||||||
|
DROP TYPE IF EXISTS notification_type;
|
||||||
|
DROP TYPE IF EXISTS trust_tier;
|
||||||
|
DROP TYPE IF EXISTS beacon_type;
|
||||||
|
|
||||||
|
DROP SCHEMA IF EXISTS auth;
|
||||||
|
|
||||||
|
-- Note: We don't drop extensions as they might be used by other databases
|
||||||
|
|
@ -0,0 +1,254 @@
|
||||||
|
-- 000001_initial_schema.up.sql
|
||||||
|
-- EXTENSIONS
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "postgis";
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE beacon_type AS ENUM ('police', 'checkpoint', 'taskForce', 'hazard', 'safety', 'community');
|
||||||
|
CREATE TYPE trust_tier AS ENUM ('new', 'trusted', 'established');
|
||||||
|
CREATE TYPE notification_type AS ENUM ('appreciate', 'chain', 'follow', 'comment', 'mention', 'follow_request', 'new_follower', 'request_accepted');
|
||||||
|
CREATE TYPE tone_label AS ENUM ('positive', 'neutral', 'mixed', 'negative', 'hostile');
|
||||||
|
CREATE TYPE post_status AS ENUM ('active', 'flagged', 'removed');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN null; END $$;
|
||||||
|
|
||||||
|
-- AUTH SCHEMA (Full Parity)
|
||||||
|
CREATE SCHEMA IF NOT EXISTS auth;
|
||||||
|
CREATE TABLE IF NOT EXISTS auth.users (
|
||||||
|
instance_id UUID,
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
aud TEXT,
|
||||||
|
role TEXT,
|
||||||
|
email TEXT,
|
||||||
|
encrypted_password TEXT,
|
||||||
|
email_confirmed_at TIMESTAMPTZ,
|
||||||
|
invited_at TIMESTAMPTZ,
|
||||||
|
confirmation_token TEXT,
|
||||||
|
confirmation_sent_at TIMESTAMPTZ,
|
||||||
|
recovery_token TEXT,
|
||||||
|
recovery_sent_at TIMESTAMPTZ,
|
||||||
|
email_change_token_new TEXT,
|
||||||
|
email_change TEXT,
|
||||||
|
email_change_sent_at TIMESTAMPTZ,
|
||||||
|
last_sign_in_at TIMESTAMPTZ,
|
||||||
|
raw_app_meta_data JSONB,
|
||||||
|
raw_user_meta_data JSONB,
|
||||||
|
is_super_admin BOOLEAN,
|
||||||
|
created_at TIMESTAMPTZ,
|
||||||
|
updated_at TIMESTAMPTZ,
|
||||||
|
phone TEXT DEFAULT NULL,
|
||||||
|
phone_confirmed_at TIMESTAMPTZ DEFAULT NULL,
|
||||||
|
phone_change TEXT DEFAULT NULL,
|
||||||
|
phone_change_sent_at TIMESTAMPTZ DEFAULT NULL,
|
||||||
|
phone_change_token TEXT DEFAULT NULL,
|
||||||
|
email_change_token_current TEXT DEFAULT NULL,
|
||||||
|
email_change_confirm_status SMALLINT DEFAULT 0,
|
||||||
|
banned_until TIMESTAMPTZ DEFAULT NULL,
|
||||||
|
reauthentication_token TEXT DEFAULT NULL,
|
||||||
|
reauthentication_sent_at TIMESTAMPTZ DEFAULT NULL,
|
||||||
|
is_sso_user BOOLEAN DEFAULT FALSE,
|
||||||
|
deleted_at TIMESTAMPTZ DEFAULT NULL,
|
||||||
|
is_anonymous BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- SUPABASE FUNCTIONS (Compatibility)
|
||||||
|
CREATE SCHEMA IF NOT EXISTS supabase_functions;
|
||||||
|
CREATE TABLE IF NOT EXISTS supabase_functions.hooks (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
hook_table_id INTEGER,
|
||||||
|
hook_name TEXT,
|
||||||
|
created_at TIMESTAMPTZ,
|
||||||
|
request_id TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CORE TABLES
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT,
|
||||||
|
supabase_id UUID UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
is_anonymous BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS profiles (
|
||||||
|
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
handle TEXT UNIQUE NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
bio TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
is_official BOOLEAN DEFAULT FALSE,
|
||||||
|
location TEXT,
|
||||||
|
website TEXT,
|
||||||
|
interests TEXT[],
|
||||||
|
avatar_url TEXT,
|
||||||
|
cover_url TEXT,
|
||||||
|
account_status TEXT DEFAULT 'active',
|
||||||
|
deactivated_at TIMESTAMPTZ,
|
||||||
|
deletion_requested_at TIMESTAMPTZ,
|
||||||
|
last_handle_change_at TIMESTAMPTZ,
|
||||||
|
beacon_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
strikes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
origin_country TEXT,
|
||||||
|
is_private BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
identity_key TEXT,
|
||||||
|
registration_id INTEGER,
|
||||||
|
encrypted_private_key TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS categories (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
slug TEXT UNIQUE NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
is_sensitive BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
default_off BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS trust_state (
|
||||||
|
user_id UUID PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
harmony_score INTEGER NOT NULL DEFAULT 50,
|
||||||
|
tier TEXT NOT NULL DEFAULT 'new',
|
||||||
|
counters JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
last_post_at TIMESTAMPTZ,
|
||||||
|
posts_today INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
tier_changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS posts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
author_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
category_id UUID REFERENCES categories(id) ON DELETE SET NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
tone_label TEXT,
|
||||||
|
cis_score NUMERIC,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
edited_at TIMESTAMPTZ,
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
allow_chain BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
chain_parent_id UUID REFERENCES posts(id) ON DELETE SET NULL,
|
||||||
|
image_url TEXT,
|
||||||
|
body_format TEXT,
|
||||||
|
background_id TEXT,
|
||||||
|
is_edited BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
tags TEXT[],
|
||||||
|
location geography(POINT),
|
||||||
|
is_beacon BOOLEAN DEFAULT FALSE,
|
||||||
|
beacon_type TEXT,
|
||||||
|
confidence_score DOUBLE PRECISION,
|
||||||
|
is_active_beacon BOOLEAN DEFAULT TRUE,
|
||||||
|
fts TSVECTOR,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
moderation_status TEXT NOT NULL DEFAULT 'approved',
|
||||||
|
visibility TEXT NOT NULL DEFAULT 'authenticated',
|
||||||
|
pinned_at TIMESTAMPTZ,
|
||||||
|
type TEXT NOT NULL DEFAULT 'post',
|
||||||
|
video_url TEXT,
|
||||||
|
thumbnail_url TEXT,
|
||||||
|
duration_ms INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS post_metrics (
|
||||||
|
post_id UUID PRIMARY KEY REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
like_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
save_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
view_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS post_likes (
|
||||||
|
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (user_id, post_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS post_saves (
|
||||||
|
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (user_id, post_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS comments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
author_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
tone_label TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS beacon_votes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
beacon_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
vote_type TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS follows (
|
||||||
|
follower_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
following_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
status TEXT NOT NULL DEFAULT 'accepted',
|
||||||
|
PRIMARY KEY (follower_id, following_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
actor_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
post_id UUID REFERENCES posts(id) ON DELETE SET NULL,
|
||||||
|
comment_id UUID REFERENCES comments(id) ON DELETE SET NULL,
|
||||||
|
is_read BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
archived_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_fcm_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
device_type TEXT,
|
||||||
|
last_updated TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(user_id, token)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS profile_privacy_settings (
|
||||||
|
user_id UUID PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
show_location BOOLEAN DEFAULT TRUE,
|
||||||
|
show_interests BOOLEAN DEFAULT TRUE,
|
||||||
|
profile_visibility TEXT DEFAULT 'public',
|
||||||
|
posts_visibility TEXT DEFAULT 'public',
|
||||||
|
saved_visibility TEXT DEFAULT 'public',
|
||||||
|
follow_request_policy TEXT DEFAULT 'anyone',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sponsored_posts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
advertiser_name TEXT, -- Added
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- INDEXES
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profiles_handle ON profiles(handle);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_author ON posts(author_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_category ON posts(category_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_location ON posts USING GIST (location);
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
-- 000002_e2ee_chat.down.sql
|
||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS consume_one_time_prekey;
|
||||||
|
DROP FUNCTION IF EXISTS get_or_create_conversation;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS e2ee_session_events;
|
||||||
|
DROP TABLE IF EXISTS e2ee_session_commands;
|
||||||
|
DROP TABLE IF EXISTS encrypted_messages;
|
||||||
|
DROP TABLE IF EXISTS encrypted_conversations;
|
||||||
|
DROP TABLE IF EXISTS signal_keys;
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
-- Create table for Signed Prekeys
|
||||||
|
CREATE TABLE IF NOT EXISTS public.signed_prekeys (
|
||||||
|
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
key_id INTEGER NOT NULL,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
signature TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (user_id, key_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create table for One-Time Prekeys
|
||||||
|
CREATE TABLE IF NOT EXISTS public.one_time_prekeys (
|
||||||
|
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
key_id INTEGER NOT NULL,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (user_id, key_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_one_time_prekeys_user_created ON public.one_time_prekeys(user_id, created_at ASC);
|
||||||
|
|
||||||
|
-- Create table for Secure Messages
|
||||||
|
CREATE TABLE IF NOT EXISTS public.secure_messages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
conversation_id UUID NOT NULL,
|
||||||
|
sender_id UUID NOT NULL REFERENCES public.users(id),
|
||||||
|
receiver_id UUID NOT NULL REFERENCES public.users(id),
|
||||||
|
ciphertext TEXT NOT NULL,
|
||||||
|
iv TEXT NOT NULL,
|
||||||
|
key_version TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_secure_messages_conv_created ON public.secure_messages(conversation_id, created_at DESC);
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
-- E2EE Backup & Recovery System Migration
|
||||||
|
-- Creates tables for device sync, cloud backups, and social recovery
|
||||||
|
|
||||||
|
-- Sync codes table for device-to-device pairing
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_codes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
code VARCHAR(6) NOT NULL,
|
||||||
|
device_fingerprint TEXT,
|
||||||
|
device_name TEXT,
|
||||||
|
expires_at TIMESTAMP NOT NULL,
|
||||||
|
used_at TIMESTAMP,
|
||||||
|
attempts INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for sync codes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_codes_code ON sync_codes(code) WHERE used_at IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_codes_expires ON sync_codes(expires_at) WHERE used_at IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_codes_user ON sync_codes(user_id, created_at DESC);
|
||||||
|
|
||||||
|
-- Cloud backups table
|
||||||
|
CREATE TABLE IF NOT EXISTS cloud_backups (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
encrypted_blob BYTEA NOT NULL,
|
||||||
|
salt BYTEA NOT NULL,
|
||||||
|
nonce BYTEA NOT NULL,
|
||||||
|
mac BYTEA NOT NULL,
|
||||||
|
version INT DEFAULT 1,
|
||||||
|
device_name TEXT,
|
||||||
|
size_bytes BIGINT,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for cloud backups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_backups_user ON cloud_backups(user_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_backups_version ON cloud_backups(user_id, version);
|
||||||
|
|
||||||
|
-- Recovery guardians table for social recovery
|
||||||
|
CREATE TABLE IF NOT EXISTS recovery_guardians (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
guardian_user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
shard_encrypted BYTEA NOT NULL,
|
||||||
|
shard_index INT NOT NULL,
|
||||||
|
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'declined', 'revoked')),
|
||||||
|
invited_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
responded_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for recovery guardians
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_guardians_user ON recovery_guardians(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_guardians_guardian ON recovery_guardians(guardian_user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_guardians_status ON recovery_guardians(status);
|
||||||
|
|
||||||
|
-- Recovery sessions table
|
||||||
|
CREATE TABLE IF NOT EXISTS recovery_sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
method VARCHAR(20) NOT NULL CHECK (method IN ('social', 'email', 'questions')),
|
||||||
|
shards_received INT DEFAULT 0,
|
||||||
|
shards_needed INT NOT NULL,
|
||||||
|
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'in_progress', 'completed', 'expired', 'failed')),
|
||||||
|
expires_at TIMESTAMP NOT NULL,
|
||||||
|
completed_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for recovery sessions
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_user ON recovery_sessions(user_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_status ON recovery_sessions(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON recovery_sessions(expires_at);
|
||||||
|
|
||||||
|
-- Recovery shard submissions table
|
||||||
|
CREATE TABLE IF NOT EXISTS recovery_shard_submissions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
session_id UUID NOT NULL REFERENCES public.recovery_sessions(id) ON DELETE CASCADE,
|
||||||
|
guardian_user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
shard_encrypted BYTEA NOT NULL,
|
||||||
|
submitted_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for shard submissions
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shards_session ON recovery_shard_submissions(session_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shards_guardian ON recovery_shard_submissions(guardian_user_id);
|
||||||
|
|
||||||
|
-- User backup preferences table
|
||||||
|
CREATE TABLE IF NOT EXISTS backup_preferences (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
cloud_backup_enabled BOOLEAN DEFAULT false,
|
||||||
|
auto_backup_enabled BOOLEAN DEFAULT false,
|
||||||
|
backup_frequency_hours INT DEFAULT 24,
|
||||||
|
last_backup_at TIMESTAMP,
|
||||||
|
backup_password_hash TEXT, -- Argon2id hash of backup password
|
||||||
|
backup_salt BYTEA, -- Salt for backup password derivation
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for backup preferences
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_backup_preferences_user ON backup_preferences(user_id);
|
||||||
|
|
||||||
|
-- Device registry table for tracking user devices
|
||||||
|
CREATE TABLE IF NOT EXISTS user_devices (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
device_fingerprint TEXT NOT NULL,
|
||||||
|
device_name TEXT,
|
||||||
|
device_type VARCHAR(20) CHECK (device_type IN ('android', 'ios', 'web', 'desktop')),
|
||||||
|
last_seen_at TIMESTAMP,
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for user devices
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devices_user ON user_devices(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devices_fingerprint ON user_devices(device_fingerprint);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devices_active ON user_devices(user_id, is_active);
|
||||||
|
|
||||||
|
-- Insert default backup preferences for existing users
|
||||||
|
INSERT INTO backup_preferences (user_id)
|
||||||
|
SELECT id FROM public.users
|
||||||
|
WHERE id NOT IN (SELECT user_id FROM backup_preferences);
|
||||||
|
|
||||||
|
-- Comments for documentation
|
||||||
|
COMMENT ON TABLE sync_codes IS 'Stores temporary 6-digit codes for device-to-device sync pairing';
|
||||||
|
COMMENT ON TABLE cloud_backups IS 'Stores encrypted user backup blobs in Firebase Storage';
|
||||||
|
COMMENT ON TABLE recovery_guardians IS 'Stores encrypted key shards for social recovery';
|
||||||
|
COMMENT ON TABLE recovery_sessions IS 'Tracks recovery attempts and progress';
|
||||||
|
COMMENT ON TABLE recovery_shard_submissions IS 'Stores individual shard submissions during recovery';
|
||||||
|
COMMENT ON TABLE backup_preferences IS 'User preferences for backup settings';
|
||||||
|
COMMENT ON TABLE user_devices IS 'Registry of user devices for sync management';
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS refresh_tokens;
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
revoked BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
-- 000010_notification_preferences.down.sql
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS notification_count_trigger ON notifications;
|
||||||
|
DROP FUNCTION IF EXISTS update_unread_notification_count();
|
||||||
|
|
||||||
|
ALTER TABLE notifications DROP COLUMN IF EXISTS group_key;
|
||||||
|
ALTER TABLE notifications DROP COLUMN IF EXISTS priority;
|
||||||
|
ALTER TABLE profiles DROP COLUMN IF EXISTS unread_notification_count;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS notification_preferences;
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
-- 000010_notification_preferences.up.sql
|
||||||
|
-- User notification preferences for granular control
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||||
|
user_id UUID PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Push notification toggles
|
||||||
|
push_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
push_likes BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
push_comments BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
push_replies BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
push_mentions BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
push_follows BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
push_follow_requests BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
push_messages BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
push_saves BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
push_beacons BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
|
||||||
|
-- Email notification toggles (for future use)
|
||||||
|
email_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
email_digest_frequency TEXT NOT NULL DEFAULT 'never', -- 'never', 'daily', 'weekly'
|
||||||
|
|
||||||
|
-- Quiet hours (UTC)
|
||||||
|
quiet_hours_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
quiet_hours_start TIME, -- e.g., '22:00:00'
|
||||||
|
quiet_hours_end TIME, -- e.g., '08:00:00'
|
||||||
|
|
||||||
|
-- Badge settings
|
||||||
|
show_badge_count BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Add unread count cache to profiles for badge display
|
||||||
|
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS unread_notification_count INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Add group_key for notification batching/grouping
|
||||||
|
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS group_key TEXT;
|
||||||
|
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS priority TEXT NOT NULL DEFAULT 'normal'; -- 'low', 'normal', 'high', 'urgent'
|
||||||
|
|
||||||
|
-- Create indexes for efficient querying
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_user_unread ON notifications(user_id, is_read) WHERE is_read = FALSE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_group_key ON notifications(group_key) WHERE group_key IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fcm_tokens_user ON user_fcm_tokens(user_id);
|
||||||
|
|
||||||
|
-- Function to update unread count on notification insert
|
||||||
|
CREATE OR REPLACE FUNCTION update_unread_notification_count()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'INSERT' THEN
|
||||||
|
UPDATE profiles SET unread_notification_count = unread_notification_count + 1 WHERE id = NEW.user_id;
|
||||||
|
ELSIF TG_OP = 'UPDATE' AND OLD.is_read = FALSE AND NEW.is_read = TRUE THEN
|
||||||
|
UPDATE profiles SET unread_notification_count = GREATEST(0, unread_notification_count - 1) WHERE id = NEW.user_id;
|
||||||
|
ELSIF TG_OP = 'DELETE' AND OLD.is_read = FALSE THEN
|
||||||
|
UPDATE profiles SET unread_notification_count = GREATEST(0, unread_notification_count - 1) WHERE id = OLD.user_id;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Create trigger for automatic badge count updates
|
||||||
|
DROP TRIGGER IF EXISTS notification_count_trigger ON notifications;
|
||||||
|
CREATE TRIGGER notification_count_trigger
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON notifications
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_unread_notification_count();
|
||||||
|
|
||||||
|
-- Initialize notification preferences for existing users
|
||||||
|
INSERT INTO notification_preferences (user_id)
|
||||||
|
SELECT id FROM profiles
|
||||||
|
ON CONFLICT (user_id) DO NOTHING;
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
-- 000011_tagging_system.down.sql
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS hashtag_count_trigger ON post_hashtags;
|
||||||
|
DROP FUNCTION IF EXISTS update_hashtag_count();
|
||||||
|
DROP FUNCTION IF EXISTS calculate_trending_scores();
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS suggested_users;
|
||||||
|
DROP TABLE IF EXISTS trending_hashtags;
|
||||||
|
DROP TABLE IF EXISTS post_mentions;
|
||||||
|
DROP TABLE IF EXISTS hashtag_follows;
|
||||||
|
DROP TABLE IF EXISTS post_hashtags;
|
||||||
|
DROP TABLE IF EXISTS hashtags;
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
-- 000011_tagging_system.up.sql
|
||||||
|
-- Complete tagging system for hashtags and mentions
|
||||||
|
|
||||||
|
-- Hashtags master table
|
||||||
|
CREATE TABLE IF NOT EXISTS hashtags (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name TEXT NOT NULL, -- lowercase, without #
|
||||||
|
display_name TEXT NOT NULL, -- original casing
|
||||||
|
use_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
trending_score FLOAT NOT NULL DEFAULT 0,
|
||||||
|
is_trending BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
is_featured BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
category TEXT, -- optional category grouping
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Post-to-hashtag junction table
|
||||||
|
CREATE TABLE IF NOT EXISTS post_hashtags (
|
||||||
|
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
hashtag_id UUID NOT NULL REFERENCES hashtags(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (post_id, hashtag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- User-to-hashtag follows (users can follow hashtags)
|
||||||
|
CREATE TABLE IF NOT EXISTS hashtag_follows (
|
||||||
|
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
hashtag_id UUID NOT NULL REFERENCES hashtags(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (user_id, hashtag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Post mentions (tracks @mentions in posts)
|
||||||
|
CREATE TABLE IF NOT EXISTS post_mentions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
mentioned_user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(post_id, mentioned_user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Trending hashtags snapshot (for discover page)
|
||||||
|
CREATE TABLE IF NOT EXISTS trending_hashtags (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
hashtag_id UUID NOT NULL REFERENCES hashtags(id) ON DELETE CASCADE,
|
||||||
|
rank INTEGER NOT NULL,
|
||||||
|
calculated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
period TEXT NOT NULL DEFAULT 'daily', -- 'hourly', 'daily', 'weekly'
|
||||||
|
post_count_in_period INTEGER NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE(hashtag_id, period)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Suggested users for discover page
|
||||||
|
CREATE TABLE IF NOT EXISTS suggested_users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
reason TEXT NOT NULL, -- 'popular', 'category', 'similar', 'new_creator'
|
||||||
|
category TEXT,
|
||||||
|
score FLOAT NOT NULL DEFAULT 0,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(user_id, reason)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_hashtags_name ON hashtags(name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_hashtags_trending ON hashtags(is_trending, trending_score DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_hashtags_use_count ON hashtags(use_count DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_post_hashtags_hashtag ON post_hashtags(hashtag_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_post_mentions_user ON post_mentions(mentioned_user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_trending_hashtags_rank ON trending_hashtags(period, rank);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_suggested_users_active ON suggested_users(is_active, score DESC);
|
||||||
|
|
||||||
|
-- Function to update hashtag use count
|
||||||
|
CREATE OR REPLACE FUNCTION update_hashtag_count()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'INSERT' THEN
|
||||||
|
UPDATE hashtags SET use_count = use_count + 1, updated_at = NOW() WHERE id = NEW.hashtag_id;
|
||||||
|
ELSIF TG_OP = 'DELETE' THEN
|
||||||
|
UPDATE hashtags SET use_count = GREATEST(0, use_count - 1), updated_at = NOW() WHERE id = OLD.hashtag_id;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS hashtag_count_trigger ON post_hashtags;
|
||||||
|
CREATE TRIGGER hashtag_count_trigger
|
||||||
|
AFTER INSERT OR DELETE ON post_hashtags
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_hashtag_count();
|
||||||
|
|
||||||
|
-- Function to calculate trending score (decay-based)
|
||||||
|
CREATE OR REPLACE FUNCTION calculate_trending_scores()
|
||||||
|
RETURNS void AS $$
|
||||||
|
DECLARE
|
||||||
|
decay_factor FLOAT := 0.95;
|
||||||
|
hours_window INTEGER := 24;
|
||||||
|
BEGIN
|
||||||
|
-- Calculate trending score based on recent usage with time decay
|
||||||
|
UPDATE hashtags h
|
||||||
|
SET
|
||||||
|
trending_score = COALESCE((
|
||||||
|
SELECT SUM(POWER(decay_factor, EXTRACT(EPOCH FROM (NOW() - ph.created_at)) / 3600))
|
||||||
|
FROM post_hashtags ph
|
||||||
|
WHERE ph.hashtag_id = h.id
|
||||||
|
AND ph.created_at > NOW() - INTERVAL '24 hours'
|
||||||
|
), 0),
|
||||||
|
is_trending = (
|
||||||
|
SELECT COUNT(*) >= 5
|
||||||
|
FROM post_hashtags ph
|
||||||
|
WHERE ph.hashtag_id = h.id
|
||||||
|
AND ph.created_at > NOW() - INTERVAL '24 hours'
|
||||||
|
),
|
||||||
|
updated_at = NOW();
|
||||||
|
|
||||||
|
-- Update trending_hashtags table
|
||||||
|
DELETE FROM trending_hashtags WHERE period = 'daily';
|
||||||
|
INSERT INTO trending_hashtags (hashtag_id, rank, period, post_count_in_period)
|
||||||
|
SELECT
|
||||||
|
h.id,
|
||||||
|
ROW_NUMBER() OVER (ORDER BY h.trending_score DESC),
|
||||||
|
'daily',
|
||||||
|
(SELECT COUNT(*) FROM post_hashtags ph WHERE ph.hashtag_id = h.id AND ph.created_at > NOW() - INTERVAL '24 hours')
|
||||||
|
FROM hashtags h
|
||||||
|
WHERE h.trending_score > 0
|
||||||
|
ORDER BY h.trending_score DESC
|
||||||
|
LIMIT 50;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- Add privacy settings to profiles
|
||||||
|
ALTER TABLE public.profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS default_post_visibility TEXT NOT NULL DEFAULT 'public',
|
||||||
|
ADD COLUMN IF NOT EXISTS is_private_profile BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- Create table for blocked users
|
||||||
|
CREATE TABLE IF NOT EXISTS public.blocks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
blocker_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||||
|
blocked_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(blocker_id, blocked_id)
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- Add missing columns to profile_privacy_settings
|
||||||
|
ALTER TABLE public.profile_privacy_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS default_post_visibility TEXT NOT NULL DEFAULT 'public',
|
||||||
|
ADD COLUMN IF NOT EXISTS is_private_profile BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- Ensure blocks table exists
|
||||||
|
CREATE TABLE IF NOT EXISTS public.blocks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
blocker_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||||
|
blocked_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(blocker_id, blocked_id)
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
-- Structural Blocking & Abuse Tracking
|
||||||
|
|
||||||
|
-- Create abuse_logs table
|
||||||
|
CREATE TABLE IF NOT EXISTS public.abuse_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
actor_id UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
|
||||||
|
blocked_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||||
|
blocked_handle TEXT NOT NULL,
|
||||||
|
actor_ip TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create reports table
|
||||||
|
CREATE TABLE IF NOT EXISTS public.reports (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
reporter_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||||
|
target_user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||||
|
post_id UUID REFERENCES public.posts(id) ON DELETE SET NULL,
|
||||||
|
comment_id UUID REFERENCES public.comments(id) ON DELETE SET NULL,
|
||||||
|
violation_type TEXT NOT NULL, -- e.g., 'hate', 'greed', 'delusion'
|
||||||
|
description TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending', -- pending, reviewed, resolved
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create pending_moderation table for AI flags
|
||||||
|
CREATE TABLE IF NOT EXISTS public.pending_moderation (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
post_id UUID REFERENCES public.posts(id) ON DELETE CASCADE,
|
||||||
|
comment_id UUID REFERENCES public.comments(id) ON DELETE CASCADE,
|
||||||
|
flag_reason TEXT NOT NULL,
|
||||||
|
scores JSONB, -- store AI scores for 'hate', 'greed', 'delusion'
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Blocking Function
|
||||||
|
CREATE OR REPLACE FUNCTION public.has_block_between(user_a UUID, user_b UUID)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN EXISTS (
|
||||||
|
SELECT 1 FROM public.blocks
|
||||||
|
WHERE (blocker_id = user_a AND blocked_id = user_b)
|
||||||
|
OR (blocker_id = user_b AND blocked_id = user_a)
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- Note: RLS implementation typically requires the database to be aware of the "current_user_id".
|
||||||
|
-- Since this is an external Go API, we will enforce structural invisibility in the Repositories/Queries
|
||||||
|
-- for now if RLS isn't fully configured with the Auth provider's claims.
|
||||||
|
-- However, we'll set up the policies anyway for parity.
|
||||||
|
|
||||||
|
-- Enable RLS
|
||||||
|
ALTER TABLE public.posts ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.comments ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Post Policy: Hide posts from/to blocked users
|
||||||
|
-- This assuming jwt.claims.sub can be passed to session configs
|
||||||
|
-- For now, we will focus on SQL Query enforcement in Repo layer which is more reliable for custom Go Backends
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
-- Add official_account_id to categories table
|
||||||
|
ALTER TABLE public.categories ADD COLUMN IF NOT EXISTS official_account_id UUID REFERENCES public.profiles(id);
|
||||||
|
|
||||||
|
-- Create index for faster lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_categories_official_account ON public.categories(official_account_id) WHERE official_account_id IS NOT NULL;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
-- 20260126_auth_revamp_v2.up.sql
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE user_status AS ENUM ('pending', 'active', 'deactivated');
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
ALTER TABLE public.users
|
||||||
|
ADD COLUMN IF NOT EXISTS status user_status DEFAULT 'active',
|
||||||
|
ADD COLUMN IF NOT EXISTS mfa_enabled BOOLEAN DEFAULT FALSE,
|
||||||
|
ADD COLUMN IF NOT EXISTS last_login TIMESTAMPTZ;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.user_mfa_secrets (
|
||||||
|
user_id UUID PRIMARY KEY REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
secret TEXT NOT NULL,
|
||||||
|
recovery_codes TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.webauthn_credentials (
|
||||||
|
id BYTEA PRIMARY KEY, -- Credential ID can be long bytes
|
||||||
|
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
public_key BYTEA NOT NULL, -- COSE encoded public key
|
||||||
|
attestation_type TEXT,
|
||||||
|
aaguid UUID,
|
||||||
|
sign_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webauthn_user_id ON public.webauthn_credentials(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.auth_tokens (
|
||||||
|
token TEXT PRIMARY KEY, -- Hashed token
|
||||||
|
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
type TEXT NOT NULL, -- 'verification', 'password_reset', 'magic_link'
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user_id ON public.auth_tokens(user_id);
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
-- Create user_category_settings table
|
||||||
|
CREATE TABLE IF NOT EXISTS public.user_category_settings (
|
||||||
|
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
category_id UUID NOT NULL REFERENCES public.categories(id) ON DELETE CASCADE,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (user_id, category_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for faster lookups by user
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_category_settings_user_id ON public.user_category_settings(user_id);
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
-- Enable UUID extension
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- Create Users table in public schema
|
||||||
|
CREATE TABLE IF NOT EXISTS public.users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
encrypted_password TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Attempt to migrate data from Supabase auth.users if it exists
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT FROM pg_tables WHERE schemaname = 'auth' AND tablename = 'users') THEN
|
||||||
|
INSERT INTO public.users (id, email, encrypted_password, created_at, updated_at)
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
encrypted_password,
|
||||||
|
created_at,
|
||||||
|
COALESCE(updated_at, created_at)
|
||||||
|
FROM auth.users
|
||||||
|
WHERE email NOT IN (SELECT email FROM public.users)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Update profiles foreign key ONLY if profiles table exists
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT FROM pg_tables WHERE schemaname = 'public' AND tablename = 'profiles') THEN
|
||||||
|
ALTER TABLE public.profiles DROP CONSTRAINT IF EXISTS profiles_id_fkey;
|
||||||
|
|
||||||
|
ALTER TABLE public.profiles
|
||||||
|
ADD CONSTRAINT profiles_id_fkey
|
||||||
|
FOREIGN KEY (id)
|
||||||
|
REFERENCES public.users(id)
|
||||||
|
ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- Down migration not strictly necessary for additive fixes, but good practice.
|
||||||
|
-- We usually don't drop columns in a fix migration to avoid data loss if rolled back accidentally.
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
-- 1. Analysis/Scoring Columns
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='posts' AND column_name='tone_label') THEN
|
||||||
|
ALTER TABLE posts ADD COLUMN tone_label TEXT;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='posts' AND column_name='cis_score') THEN
|
||||||
|
ALTER TABLE posts ADD COLUMN cis_score NUMERIC;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='posts' AND column_name='moderation_status') THEN
|
||||||
|
ALTER TABLE posts ADD COLUMN moderation_status TEXT NOT NULL DEFAULT 'approved';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 2. Post Configuration/Metadata
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='posts' AND column_name='visibility') THEN
|
||||||
|
ALTER TABLE posts ADD COLUMN visibility TEXT NOT NULL DEFAULT 'authenticated';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='posts' AND column_name='expires_at') THEN
|
||||||
|
ALTER TABLE posts ADD COLUMN expires_at TIMESTAMPTZ;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='posts' AND column_name='body_format') THEN
|
||||||
|
ALTER TABLE posts ADD COLUMN body_format TEXT;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='posts' AND column_name='background_id') THEN
|
||||||
|
ALTER TABLE posts ADD COLUMN background_id TEXT;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='posts' AND column_name='tags') THEN
|
||||||
|
ALTER TABLE posts ADD COLUMN tags TEXT[];
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 3. Media
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='posts' AND column_name='thumbnail_url') THEN
|
||||||
|
ALTER TABLE posts ADD COLUMN thumbnail_url TEXT;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='posts' AND column_name='video_url') THEN
|
||||||
|
ALTER TABLE posts ADD COLUMN video_url TEXT;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='posts' AND column_name='duration_ms') THEN
|
||||||
|
ALTER TABLE posts ADD COLUMN duration_ms INTEGER;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 4. Beacons (Geo)
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='posts' AND column_name='location') THEN
|
||||||
|
-- Assuming PostGIS extension enabled
|
||||||
|
ALTER TABLE posts ADD COLUMN location geography(POINT);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='posts' AND column_name='is_beacon') THEN
|
||||||
|
ALTER TABLE posts ADD COLUMN is_beacon BOOLEAN DEFAULT FALSE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='posts' AND column_name='beacon_type') THEN
|
||||||
|
ALTER TABLE posts ADD COLUMN beacon_type TEXT;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='posts' AND column_name='confidence_score') THEN
|
||||||
|
ALTER TABLE posts ADD COLUMN confidence_score DOUBLE PRECISION;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='posts' AND column_name='is_active_beacon') THEN
|
||||||
|
ALTER TABLE posts ADD COLUMN is_active_beacon BOOLEAN DEFAULT FALSE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
END $$;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- 20260126_profile_onboarding.up.sql
|
||||||
|
|
||||||
|
ALTER TABLE public.profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS has_completed_onboarding BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- Ensure auth_tokens table exists (covered in previous migration, but safe to re-assert or skip)
|
||||||
|
-- This specific migration focuses on the profile attribute.
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
-- 20260126000007_auth_security.down.sql
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS public.webauthn_credentials;
|
||||||
|
DROP TABLE IF EXISTS public.user_mfa_secrets;
|
||||||
|
DROP TABLE IF EXISTS public.verification_tokens;
|
||||||
|
ALTER TABLE public.profiles DROP COLUMN IF EXISTS has_completed_onboarding;
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
-- 20260126000007_auth_security.up.sql
|
||||||
|
|
||||||
|
-- 1. Ensure has_completed_onboarding exists (idempotent)
|
||||||
|
ALTER TABLE public.profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS has_completed_onboarding BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- 2. Create verification_tokens table
|
||||||
|
CREATE TABLE IF NOT EXISTS public.verification_tokens (
|
||||||
|
token_hash VARCHAR(64) PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3. Create user_mfa_secrets (TOTP)
|
||||||
|
CREATE TABLE IF NOT EXISTS public.user_mfa_secrets (
|
||||||
|
user_id UUID PRIMARY KEY REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
secret_key TEXT NOT NULL,
|
||||||
|
recovery_codes TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 4. Create webauthn_credentials (Passkeys)
|
||||||
|
CREATE TABLE IF NOT EXISTS public.webauthn_credentials (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
credential_id TEXT NOT NULL, -- The base64 URL encoded ID
|
||||||
|
public_key TEXT NOT NULL, -- The public key
|
||||||
|
attestation_type TEXT,
|
||||||
|
aaguid UUID,
|
||||||
|
sign_count INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
CONSTRAINT unique_credential_id UNIQUE (credential_id)
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- This migration is a one-way data repair operation.
|
||||||
|
-- Reverting would require knowing the specific R2 account ID which was replaced.
|
||||||
|
-- No action taken for down migration.
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
-- Fix R2 URLs in profiles
|
||||||
|
UPDATE profiles
|
||||||
|
SET avatar_url = REGEXP_REPLACE(avatar_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://img.sojorn.net/', 'g')
|
||||||
|
WHERE avatar_url LIKE '%r2.cloudflarestorage.com%';
|
||||||
|
|
||||||
|
UPDATE profiles
|
||||||
|
SET cover_url = REGEXP_REPLACE(cover_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://img.sojorn.net/', 'g')
|
||||||
|
WHERE cover_url LIKE '%r2.cloudflarestorage.com%';
|
||||||
|
|
||||||
|
-- Fix R2 URLs in posts
|
||||||
|
UPDATE posts
|
||||||
|
SET image_url = REGEXP_REPLACE(image_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://img.sojorn.net/', 'g')
|
||||||
|
WHERE image_url LIKE '%r2.cloudflarestorage.com%';
|
||||||
|
|
||||||
|
UPDATE posts
|
||||||
|
SET video_url = REGEXP_REPLACE(video_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://quips.sojorn.net/', 'g')
|
||||||
|
WHERE video_url LIKE '%r2.cloudflarestorage.com%';
|
||||||
|
|
||||||
|
UPDATE posts
|
||||||
|
SET thumbnail_url = REGEXP_REPLACE(thumbnail_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://img.sojorn.net/', 'g')
|
||||||
|
WHERE thumbnail_url LIKE '%r2.cloudflarestorage.com%';
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
ALTER TABLE public.follows DROP CONSTRAINT IF EXISTS follows_status_check;
|
||||||
|
ALTER TABLE public.follows DROP COLUMN IF EXISTS status;
|
||||||
|
ALTER TABLE public.profiles DROP COLUMN IF EXISTS is_official;
|
||||||
|
ALTER TABLE public.profiles DROP COLUMN IF EXISTS is_private;
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
-- Add privacy settings to profiles if they don't exist
|
||||||
|
ALTER TABLE public.profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS is_private BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS is_official BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- Add status column to follows for request logic
|
||||||
|
ALTER TABLE public.follows
|
||||||
|
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'accepted';
|
||||||
|
|
||||||
|
-- Add check constraint to ensure valid status values
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'follows_status_check') THEN
|
||||||
|
ALTER TABLE public.follows
|
||||||
|
ADD CONSTRAINT follows_status_check CHECK (status IN ('pending', 'accepted'));
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
-- Optimizing E2EE Schema
|
||||||
|
-- Migration ID: 20260127000002
|
||||||
|
-- Description: Creates properly structured tables for Signal Protocol keys and messages to replace JSON blobs.
|
||||||
|
|
||||||
|
-- 1. One-Time Prekeys
|
||||||
|
-- Stores individual one-time keys. Critical for atomic "fetch and consume".
|
||||||
|
CREATE TABLE IF NOT EXISTS public.one_time_prekeys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL, -- References profiles(id) usually, but loosen constraint if profiles not strictly sync'd yet
|
||||||
|
key_id INTEGER NOT NULL,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
signature TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT unique_otk_user_key UNIQUE (user_id, key_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for finding keys quickly by user
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_otk_user_id ON public.one_time_prekeys(user_id);
|
||||||
|
|
||||||
|
-- 2. Signed Prekeys
|
||||||
|
-- Semi-permanent keys signed by identity key.
|
||||||
|
CREATE TABLE IF NOT EXISTS public.signed_prekeys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
key_id INTEGER NOT NULL,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
signature TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT unique_spk_user_key UNIQUE (user_id, key_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index to quickly get the latest signed prekey
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_spk_user_created ON public.signed_prekeys(user_id, created_at DESC);
|
||||||
|
|
||||||
|
-- 3. Identities
|
||||||
|
-- Long-term identity keys.
|
||||||
|
CREATE TABLE IF NOT EXISTS public.identities (
|
||||||
|
user_id UUID PRIMARY KEY,
|
||||||
|
registration_id INTEGER NOT NULL,
|
||||||
|
public_key TEXT NOT NULL, -- Identity Key Public
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 4. Secure Messages
|
||||||
|
-- Optimized storage for E2EE payloads.
|
||||||
|
-- Note: Replaces or complements 'encrypted_messages' depending on migration strategy.
|
||||||
|
CREATE TABLE IF NOT EXISTS public.secure_messages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
conversation_id UUID NOT NULL,
|
||||||
|
sender_id UUID NOT NULL,
|
||||||
|
receiver_id UUID NOT NULL,
|
||||||
|
ciphertext TEXT NOT NULL, -- Base64 encoded encrypted payload
|
||||||
|
iv TEXT NOT NULL, -- Base64 encoded IV
|
||||||
|
key_version TEXT NOT NULL, -- e.g. "v1", "signal-session-id"
|
||||||
|
message_type INTEGER DEFAULT 1,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
delivered_at TIMESTAMPTZ,
|
||||||
|
read_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Composite index for fast pagination of chat history
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_secure_msg_conv_created ON public.secure_messages(conversation_id, created_at DESC);
|
||||||
|
|
||||||
|
-- 5. E2EE Sessions (Optional/Future Proofing)
|
||||||
|
-- Stores session state if server-side session management is desired
|
||||||
|
CREATE TABLE IF NOT EXISTS public.e2ee_sessions (
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
device_id UUID NOT NULL,
|
||||||
|
session_state TEXT NOT NULL, -- Serialized session state
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (user_id, device_id)
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE public.secure_messages
|
||||||
|
DROP COLUMN IF EXISTS message_header;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE public.secure_messages
|
||||||
|
ADD COLUMN IF NOT EXISTS message_header TEXT;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS post_reactions;
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS post_reactions (
|
||||||
|
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
emoji TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (user_id, post_id, emoji)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_post_reactions_post_id ON post_reactions(post_id);
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- Enable pg_trgm extension
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||||
|
|
||||||
|
-- Create GIN indices for profiles
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profiles_handle_trgm ON profiles USING gin (handle gin_trgm_ops);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profiles_display_name_trgm ON profiles USING gin (display_name gin_trgm_ops);
|
||||||
|
|
||||||
|
-- Create GIN index for post body
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_body_trgm ON posts USING gin (body gin_trgm_ops);
|
||||||
|
|
||||||
|
-- Create GIN index for post tags
|
||||||
|
-- Assuming tags is a text[] column
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_tags_gin ON posts USING gin (tags);
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- Rollback circle privacy feature
|
||||||
|
DROP FUNCTION IF EXISTS public.is_in_circle(UUID, UUID);
|
||||||
|
DROP TABLE IF EXISTS public.circle_members;
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
-- Circle (Close Friends) Privacy Feature
|
||||||
|
-- Allows users to share posts only with a specific inner circle
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.circle_members (
|
||||||
|
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||||
|
member_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (user_id, member_id),
|
||||||
|
-- Prevent self-addition
|
||||||
|
CHECK (user_id != member_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for fast circle membership checks
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_circle_members_user_id ON public.circle_members(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_circle_members_member_id ON public.circle_members(member_id);
|
||||||
|
|
||||||
|
-- Helper function to check if a user is in another user's circle
|
||||||
|
CREATE OR REPLACE FUNCTION public.is_in_circle(circle_owner_id UUID, potential_member_id UUID)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN EXISTS (
|
||||||
|
SELECT 1 FROM public.circle_members
|
||||||
|
WHERE user_id = circle_owner_id AND member_id = potential_member_id
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER STABLE;
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
-- Rollback migration: update sojorn.net back to gosojorn.com
|
||||||
|
|
||||||
|
-- Update profiles
|
||||||
|
UPDATE profiles
|
||||||
|
SET avatar_url = REPLACE(avatar_url, 'sojorn.net', 'gosojorn.com')
|
||||||
|
WHERE avatar_url LIKE '%sojorn.net%' AND avatar_url LIKE '%img.%';
|
||||||
|
|
||||||
|
UPDATE profiles
|
||||||
|
SET cover_url = REPLACE(cover_url, 'sojorn.net', 'gosojorn.com')
|
||||||
|
WHERE cover_url LIKE '%sojorn.net%' AND cover_url LIKE '%img.%';
|
||||||
|
|
||||||
|
-- Update posts
|
||||||
|
UPDATE posts
|
||||||
|
SET image_url = REPLACE(image_url, 'sojorn.net', 'gosojorn.com')
|
||||||
|
WHERE image_url LIKE '%sojorn.net%' AND image_url LIKE '%img.%';
|
||||||
|
|
||||||
|
UPDATE posts
|
||||||
|
SET video_url = REPLACE(video_url, 'sojorn.net', 'gosojorn.com')
|
||||||
|
WHERE video_url LIKE '%sojorn.net%' AND video_url LIKE '%quips.%';
|
||||||
|
|
||||||
|
UPDATE posts
|
||||||
|
SET thumbnail_url = REPLACE(thumbnail_url, 'sojorn.net', 'gosojorn.com')
|
||||||
|
WHERE thumbnail_url LIKE '%sojorn.net%' AND thumbnail_url LIKE '%img.%';
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
-- Migration to update all legacy gosojorn.com URLs to sojorn.net
|
||||||
|
|
||||||
|
-- Update profiles
|
||||||
|
UPDATE profiles
|
||||||
|
SET avatar_url = REPLACE(avatar_url, 'gosojorn.com', 'sojorn.net')
|
||||||
|
WHERE avatar_url LIKE '%gosojorn.com%';
|
||||||
|
|
||||||
|
UPDATE profiles
|
||||||
|
SET cover_url = REPLACE(cover_url, 'gosojorn.com', 'sojorn.net')
|
||||||
|
WHERE cover_url LIKE '%gosojorn.com%';
|
||||||
|
|
||||||
|
-- Update posts
|
||||||
|
UPDATE posts
|
||||||
|
SET image_url = REPLACE(image_url, 'gosojorn.com', 'sojorn.net')
|
||||||
|
WHERE image_url LIKE '%gosojorn.com%';
|
||||||
|
|
||||||
|
UPDATE posts
|
||||||
|
SET video_url = REPLACE(video_url, 'gosojorn.com', 'sojorn.net')
|
||||||
|
WHERE video_url LIKE '%gosojorn.com%';
|
||||||
|
|
||||||
|
UPDATE posts
|
||||||
|
SET thumbnail_url = REPLACE(thumbnail_url, 'gosojorn.com', 'sojorn.net')
|
||||||
|
WHERE thumbnail_url LIKE '%gosojorn.com%';
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
-- Remove triggers
|
||||||
|
DROP TRIGGER IF EXISTS moderation_flags_updated_at ON moderation_flags;
|
||||||
|
DROP TRIGGER IF EXISTS user_status_change_log ON users;
|
||||||
|
|
||||||
|
-- Remove trigger functions
|
||||||
|
DROP FUNCTION IF EXISTS update_moderation_flags_updated_at();
|
||||||
|
DROP FUNCTION IF EXISTS log_user_status_change();
|
||||||
|
|
||||||
|
-- Remove indexes
|
||||||
|
DROP INDEX IF EXISTS idx_moderation_flags_post_id;
|
||||||
|
DROP INDEX IF EXISTS idx_moderation_flags_comment_id;
|
||||||
|
DROP INDEX IF EXISTS idx_moderation_flags_status;
|
||||||
|
DROP INDEX IF EXISTS idx_moderation_flags_created_at;
|
||||||
|
DROP INDEX IF EXISTS idx_moderation_flags_scores_gin;
|
||||||
|
DROP INDEX IF EXISTS idx_users_status;
|
||||||
|
DROP INDEX IF EXISTS idx_user_status_history_user_id;
|
||||||
|
DROP INDEX IF EXISTS idx_user_status_history_created_at;
|
||||||
|
|
||||||
|
-- Remove tables
|
||||||
|
DROP TABLE IF EXISTS user_status_history;
|
||||||
|
DROP TABLE IF EXISTS moderation_flags;
|
||||||
|
|
||||||
|
-- Remove status column from users table
|
||||||
|
ALTER TABLE users DROP COLUMN IF EXISTS status;
|
||||||
|
|
||||||
|
-- Remove comments
|
||||||
|
COMMENT ON TABLE moderation_flags IS NULL;
|
||||||
|
COMMENT ON TABLE user_status_history IS NULL;
|
||||||
|
COMMENT ON COLUMN users.status IS NULL;
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
-- Create moderation_flags table for AI-powered content moderation
|
||||||
|
CREATE TABLE IF NOT EXISTS moderation_flags (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
post_id UUID REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
comment_id UUID REFERENCES comments(id) ON DELETE CASCADE,
|
||||||
|
flag_reason TEXT NOT NULL,
|
||||||
|
scores JSONB NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected', 'escalated')),
|
||||||
|
reviewed_by UUID REFERENCES users(id),
|
||||||
|
reviewed_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Ensure at least one of post_id or comment_id is set
|
||||||
|
CONSTRAINT moderation_flags_content_check CHECK (
|
||||||
|
(post_id IS NOT NULL) OR (comment_id IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Add indexes for performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_moderation_flags_post_id ON moderation_flags(post_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_moderation_flags_comment_id ON moderation_flags(comment_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_moderation_flags_status ON moderation_flags(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_moderation_flags_created_at ON moderation_flags(created_at);
|
||||||
|
|
||||||
|
-- Add GIN index for JSONB scores to enable efficient querying
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_moderation_flags_scores_gin ON moderation_flags USING GIN(scores);
|
||||||
|
|
||||||
|
-- Add status column to users table for user moderation
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'banned'));
|
||||||
|
|
||||||
|
-- Add index for user status queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_status ON users(status);
|
||||||
|
|
||||||
|
-- Create user_status_history table to track status changes
|
||||||
|
CREATE TABLE IF NOT EXISTS user_status_history (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
old_status TEXT,
|
||||||
|
new_status TEXT NOT NULL,
|
||||||
|
reason TEXT,
|
||||||
|
changed_by UUID REFERENCES users(id),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Add index for user status history queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_status_history_user_id ON user_status_history(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_status_history_created_at ON user_status_history(created_at);
|
||||||
|
|
||||||
|
-- Create trigger to update updated_at timestamp
|
||||||
|
CREATE OR REPLACE FUNCTION update_moderation_flags_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ language 'plpgsql';
|
||||||
|
|
||||||
|
CREATE TRIGGER moderation_flags_updated_at
|
||||||
|
BEFORE UPDATE ON moderation_flags
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_moderation_flags_updated_at();
|
||||||
|
|
||||||
|
-- Create trigger to track user status changes
|
||||||
|
CREATE OR REPLACE FUNCTION log_user_status_change()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
IF OLD.status IS DISTINCT FROM NEW.status THEN
|
||||||
|
INSERT INTO user_status_history (user_id, old_status, new_status, changed_by)
|
||||||
|
VALUES (NEW.id, OLD.status, NEW.status, NEW.id);
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ language 'plpgsql';
|
||||||
|
|
||||||
|
CREATE TRIGGER user_status_change_log
|
||||||
|
BEFORE UPDATE ON users
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION log_user_status_change();
|
||||||
|
|
||||||
|
-- Grant permissions to Directus
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON moderation_flags TO directus;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON user_status_history TO directus;
|
||||||
|
GRANT SELECT, UPDATE ON users TO directus;
|
||||||
|
GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO directus;
|
||||||
|
|
||||||
|
-- Add comments for Directus UI
|
||||||
|
COMMENT ON TABLE moderation_flags IS 'AI-powered content moderation flags for posts and comments';
|
||||||
|
COMMENT ON COLUMN moderation_flags.id IS 'Unique identifier for the moderation flag';
|
||||||
|
COMMENT ON COLUMN moderation_flags.post_id IS 'Reference to the post being moderated';
|
||||||
|
COMMENT ON COLUMN moderation_flags.comment_id IS 'Reference to the comment being moderated';
|
||||||
|
COMMENT ON COLUMN moderation_flags.flag_reason IS 'Primary reason for flag (hate, greed, delusion, etc.)';
|
||||||
|
COMMENT ON COLUMN moderation_flags.scores IS 'JSON object containing detailed analysis scores';
|
||||||
|
COMMENT ON COLUMN moderation_flags.status IS 'Current moderation status (pending, approved, rejected, escalated)';
|
||||||
|
COMMENT ON COLUMN moderation_flags.reviewed_by IS 'Admin who reviewed this flag';
|
||||||
|
COMMENT ON COLUMN moderation_flags.reviewed_at IS 'When this flag was reviewed';
|
||||||
|
|
||||||
|
COMMENT ON TABLE user_status_history IS 'History of user status changes for audit trail';
|
||||||
|
COMMENT ON COLUMN user_status_history.user_id IS 'User whose status changed';
|
||||||
|
COMMENT ON COLUMN user_status_history.old_status IS 'Previous status before change';
|
||||||
|
COMMENT ON COLUMN user_status_history.new_status IS 'New status after change';
|
||||||
|
COMMENT ON COLUMN user_status_history.reason IS 'Reason for status change';
|
||||||
|
COMMENT ON COLUMN user_status_history.changed_by IS 'Admin who made the change';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN users.status IS 'Current user moderation status (active, suspended, banned)';
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
-- Create moderation_flags table for AI-powered content moderation
|
||||||
|
CREATE TABLE IF NOT EXISTS moderation_flags (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
post_id UUID,
|
||||||
|
comment_id UUID,
|
||||||
|
flag_reason TEXT NOT NULL,
|
||||||
|
scores JSONB NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected', 'escalated')),
|
||||||
|
reviewed_by UUID,
|
||||||
|
reviewed_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Ensure at least one of post_id or comment_id is set
|
||||||
|
CONSTRAINT moderation_flags_content_check CHECK (
|
||||||
|
(post_id IS NOT NULL) OR (comment_id IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Add indexes for performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_moderation_flags_post_id ON moderation_flags(post_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_moderation_flags_comment_id ON moderation_flags(comment_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_moderation_flags_status ON moderation_flags(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_moderation_flags_created_at ON moderation_flags(created_at);
|
||||||
|
|
||||||
|
-- Add GIN index for JSONB scores to enable efficient querying
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_moderation_flags_scores_gin ON moderation_flags USING GIN(scores);
|
||||||
|
|
||||||
|
-- Add status column to users table for user moderation (if not exists)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='status') THEN
|
||||||
|
ALTER TABLE users ADD COLUMN status TEXT DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'banned'));
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_status ON users(status);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Create user_status_history table to track status changes
|
||||||
|
CREATE TABLE IF NOT EXISTS user_status_history (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
old_status TEXT,
|
||||||
|
new_status TEXT NOT NULL,
|
||||||
|
reason TEXT,
|
||||||
|
changed_by UUID,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Add index for user status history queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_status_history_user_id ON user_status_history(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_status_history_created_at ON user_status_history(created_at);
|
||||||
|
|
||||||
|
-- Create trigger to update updated_at timestamp
|
||||||
|
CREATE OR REPLACE FUNCTION update_moderation_flags_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ language 'plpgsql';
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS moderation_flags_updated_at ON moderation_flags;
|
||||||
|
CREATE TRIGGER moderation_flags_updated_at
|
||||||
|
BEFORE UPDATE ON moderation_flags
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_moderation_flags_updated_at();
|
||||||
|
|
||||||
|
-- Create trigger to track user status changes
|
||||||
|
CREATE OR REPLACE FUNCTION log_user_status_change()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
IF OLD.status IS DISTINCT FROM NEW.status THEN
|
||||||
|
INSERT INTO user_status_history (user_id, old_status, new_status, reason, changed_by)
|
||||||
|
VALUES (NEW.id, OLD.status, NEW.status, 'Status changed by system', NEW.id);
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ language 'plpgsql';
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS user_status_change_log ON users;
|
||||||
|
CREATE TRIGGER user_status_change_log
|
||||||
|
BEFORE UPDATE ON users
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION log_user_status_change();
|
||||||
|
|
||||||
|
-- Grant permissions to postgres user (Directus will connect as postgres)
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON moderation_flags TO postgres;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON user_status_history TO postgres;
|
||||||
|
GRANT SELECT, UPDATE ON users TO postgres;
|
||||||
|
|
||||||
|
-- Add comments for Directus UI
|
||||||
|
COMMENT ON TABLE moderation_flags IS 'AI-powered content moderation flags for posts and comments';
|
||||||
|
COMMENT ON COLUMN moderation_flags.id IS 'Unique identifier for the moderation flag';
|
||||||
|
COMMENT ON COLUMN moderation_flags.post_id IS 'Reference to the post being moderated';
|
||||||
|
COMMENT ON COLUMN moderation_flags.comment_id IS 'Reference to the comment being moderated';
|
||||||
|
COMMENT ON COLUMN moderation_flags.flag_reason IS 'Primary reason for flag (hate, greed, delusion, etc.)';
|
||||||
|
COMMENT ON COLUMN moderation_flags.scores IS 'JSON object containing detailed analysis scores';
|
||||||
|
COMMENT ON COLUMN moderation_flags.status IS 'Current moderation status (pending, approved, rejected, escalated)';
|
||||||
|
COMMENT ON COLUMN moderation_flags.reviewed_by IS 'Admin who reviewed this flag';
|
||||||
|
COMMENT ON COLUMN moderation_flags.reviewed_at IS 'When this flag was reviewed';
|
||||||
|
|
||||||
|
COMMENT ON TABLE user_status_history IS 'History of user status changes for audit trail';
|
||||||
|
COMMENT ON COLUMN user_status_history.user_id IS 'User whose status changed';
|
||||||
|
COMMENT ON COLUMN user_status_history.old_status IS 'Previous status before change';
|
||||||
|
COMMENT ON COLUMN user_status_history.new_status IS 'New status after change';
|
||||||
|
COMMENT ON COLUMN user_status_history.reason IS 'Reason for status change';
|
||||||
|
COMMENT ON COLUMN user_status_history.changed_by IS 'Admin who made the change';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN users.status IS 'Current user moderation status (active, suspended, banned)';
|
||||||
|
|
@ -0,0 +1,192 @@
|
||||||
|
-- User Appeal System Migration
|
||||||
|
-- Creates tables for tracking user violations, appeals, and ban management
|
||||||
|
|
||||||
|
-- User Violations Table
|
||||||
|
CREATE TABLE IF NOT EXISTS user_violations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
moderation_flag_id UUID NOT NULL REFERENCES moderation_flags(id) ON DELETE CASCADE,
|
||||||
|
violation_type VARCHAR(20) NOT NULL CHECK (violation_type IN ('hard_violation', 'soft_violation')),
|
||||||
|
violation_reason TEXT NOT NULL,
|
||||||
|
severity_score DECIMAL(3,2) NOT NULL CHECK (severity_score >= 0.0 AND severity_score <= 1.0),
|
||||||
|
is_appealable BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
appeal_deadline TIMESTAMP WITH TIME ZONE,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'appealed', 'upheld', 'overturned', 'expired')),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- User Appeals Table
|
||||||
|
CREATE TABLE IF NOT EXISTS user_appeals (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_violation_id UUID NOT NULL REFERENCES user_violations(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
appeal_reason TEXT NOT NULL,
|
||||||
|
appeal_context TEXT,
|
||||||
|
evidence_urls JSONB DEFAULT '[]',
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'reviewing', 'approved', 'rejected', 'withdrawn')),
|
||||||
|
reviewed_by UUID REFERENCES directus_users(id),
|
||||||
|
review_decision TEXT,
|
||||||
|
reviewed_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- User Violation History (for tracking patterns)
|
||||||
|
CREATE TABLE IF NOT EXISTS user_violation_history (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
violation_date DATE NOT NULL,
|
||||||
|
total_violations INTEGER NOT NULL DEFAULT 0,
|
||||||
|
hard_violations INTEGER NOT NULL DEFAULT 0,
|
||||||
|
soft_violations INTEGER NOT NULL DEFAULT 0,
|
||||||
|
appeals_filed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
appeals_upheld INTEGER NOT NULL DEFAULT 0,
|
||||||
|
appeals_overturned INTEGER NOT NULL DEFAULT 0,
|
||||||
|
current_status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (current_status IN ('active', 'suspended', 'banned')),
|
||||||
|
ban_expiry TIMESTAMP WITH TIME ZONE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
UNIQUE(user_id, violation_date)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Appeal Guidelines (configurable rules)
|
||||||
|
CREATE TABLE IF NOT EXISTS appeal_guidelines (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
violation_type VARCHAR(20) NOT NULL,
|
||||||
|
max_appeals_per_month INTEGER NOT NULL DEFAULT 3,
|
||||||
|
appeal_window_hours INTEGER NOT NULL DEFAULT 72,
|
||||||
|
auto_ban_threshold INTEGER NOT NULL DEFAULT 5,
|
||||||
|
hard_violation_ban_threshold INTEGER NOT NULL DEFAULT 2,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Insert default appeal guidelines
|
||||||
|
INSERT INTO appeal_guidelines (violation_type, max_appeals_per_month, appeal_window_hours, auto_ban_threshold, hard_violation_ban_threshold)
|
||||||
|
VALUES
|
||||||
|
('hard_violation', 0, 0, 5, 2),
|
||||||
|
('soft_violation', 3, 72, 8, 3)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Indexes for performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_violations_user_id ON user_violations(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_violations_status ON user_violations(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_violations_created_at ON user_violations(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_appeals_user_id ON user_appeals(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_appeals_status ON user_appeals(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_violation_history_user_id ON user_violation_history(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_violation_history_date ON user_violation_history(violation_date);
|
||||||
|
|
||||||
|
-- Functions to automatically detect violation type and create violations
|
||||||
|
CREATE OR REPLACE FUNCTION create_user_violation(
|
||||||
|
p_user_id UUID,
|
||||||
|
p_moderation_flag_id UUID,
|
||||||
|
p_flag_reason TEXT,
|
||||||
|
p_scores JSONB
|
||||||
|
) RETURNS UUID AS $$
|
||||||
|
DECLARE
|
||||||
|
v_violation_id UUID;
|
||||||
|
v_violation_type TEXT;
|
||||||
|
v_severity DECIMAL;
|
||||||
|
v_is_appealable BOOLEAN;
|
||||||
|
v_appeal_deadline TIMESTAMP WITH TIME ZONE;
|
||||||
|
BEGIN
|
||||||
|
-- Determine violation type based on scores and reason
|
||||||
|
CASE
|
||||||
|
WHEN p_flag_reason IN ('hate') AND (p_scores->>'hate')::DECIMAL > 0.8 THEN
|
||||||
|
BEGIN
|
||||||
|
v_violation_type := 'hard_violation';
|
||||||
|
v_severity := (p_scores->>'hate')::DECIMAL;
|
||||||
|
v_is_appealable := false;
|
||||||
|
v_appeal_deadline := NULL;
|
||||||
|
END;
|
||||||
|
WHEN p_flag_reason IN ('hate', 'violence', 'sexual') AND (p_scores->>'hate')::DECIMAL > 0.6 THEN
|
||||||
|
BEGIN
|
||||||
|
v_violation_type := 'hard_violation';
|
||||||
|
v_severity := GREATEST((p_scores->>'hate')::DECIMAL, (p_scores->>'greed')::DECIMAL, (p_scores->>'delusion')::DECIMAL);
|
||||||
|
v_is_appealable := false;
|
||||||
|
v_appeal_deadline := NULL;
|
||||||
|
END;
|
||||||
|
ELSE
|
||||||
|
BEGIN
|
||||||
|
v_violation_type := 'soft_violation';
|
||||||
|
v_severity := GREATEST((p_scores->>'hate')::DECIMAL, (p_scores->>'greed')::DECIMAL, (p_scores->>'delusion')::DECIMAL);
|
||||||
|
v_is_appealable := true;
|
||||||
|
v_appeal_deadline := NOW() + (SELECT appeal_window_hours FROM appeal_guidelines WHERE violation_type = 'soft_violation' AND is_active = true LIMIT 1) * INTERVAL '1 hour';
|
||||||
|
END;
|
||||||
|
END CASE;
|
||||||
|
|
||||||
|
-- Create the violation record
|
||||||
|
INSERT INTO user_violations (user_id, moderation_flag_id, violation_type, violation_reason, severity_score, is_appealable, appeal_deadline)
|
||||||
|
VALUES (p_user_id, p_moderation_flag_id, v_violation_type, p_flag_reason, v_severity, v_is_appealable, v_appeal_deadline)
|
||||||
|
RETURNING id INTO v_violation_id;
|
||||||
|
|
||||||
|
-- Update violation history
|
||||||
|
INSERT INTO user_violation_history (user_id, violation_date, total_violations, hard_violations, soft_violations)
|
||||||
|
VALUES (p_user_id, CURRENT_DATE, 1,
|
||||||
|
CASE WHEN v_violation_type = 'hard_violation' THEN 1 ELSE 0 END,
|
||||||
|
CASE WHEN v_violation_type = 'soft_violation' THEN 1 ELSE 0 END)
|
||||||
|
ON CONFLICT (user_id, violation_date)
|
||||||
|
DO UPDATE SET
|
||||||
|
total_violations = user_violation_history.total_violations + 1,
|
||||||
|
hard_violations = user_violation_history.hard_violations + CASE WHEN v_violation_type = 'hard_violation' THEN 1 ELSE 0 END,
|
||||||
|
soft_violations = user_violation_history.soft_violations + CASE WHEN v_violation_type = 'soft_violation' THEN 1 ELSE 0 END,
|
||||||
|
updated_at = NOW();
|
||||||
|
|
||||||
|
-- Check for auto-ban conditions
|
||||||
|
PERFORM check_user_ban_status(p_user_id);
|
||||||
|
|
||||||
|
RETURN v_violation_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Function to check if user should be banned
|
||||||
|
CREATE OR REPLACE FUNCTION check_user_ban_status(p_user_id UUID) RETURNS BOOLEAN AS $$
|
||||||
|
DECLARE
|
||||||
|
v_hard_count INTEGER;
|
||||||
|
v_total_count INTEGER;
|
||||||
|
v_ban_threshold INTEGER;
|
||||||
|
v_hard_ban_threshold INTEGER;
|
||||||
|
BEGIN
|
||||||
|
-- Get counts from last 30 days
|
||||||
|
SELECT COUNT(*), SUM(CASE WHEN violation_type = 'hard_violation' THEN 1 ELSE 0 END)
|
||||||
|
INTO v_total_count, v_hard_count
|
||||||
|
FROM user_violations
|
||||||
|
WHERE user_id = p_user_id
|
||||||
|
AND created_at >= NOW() - INTERVAL '30 days';
|
||||||
|
|
||||||
|
-- Get thresholds
|
||||||
|
SELECT auto_ban_threshold, hard_violation_ban_threshold
|
||||||
|
INTO v_ban_threshold, v_hard_ban_threshold
|
||||||
|
FROM appeal_guidelines
|
||||||
|
WHERE is_active = true
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- Check ban conditions
|
||||||
|
IF v_hard_count >= v_hard_ban_threshold OR v_total_count >= v_ban_threshold THEN
|
||||||
|
-- Ban the user
|
||||||
|
UPDATE users
|
||||||
|
SET status = 'banned', updated_at = NOW()
|
||||||
|
WHERE id = p_user_id;
|
||||||
|
|
||||||
|
-- Update violation history
|
||||||
|
UPDATE user_violation_history
|
||||||
|
SET current_status = 'banned', updated_at = NOW()
|
||||||
|
WHERE user_id = p_user_id AND violation_date = CURRENT_DATE;
|
||||||
|
|
||||||
|
RETURN true;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN false;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Add to Directus collections
|
||||||
|
INSERT INTO directus_collections (collection, icon, note, hidden, singleton) VALUES
|
||||||
|
('user_violations', 'warning', 'User violations and moderation records', false, false),
|
||||||
|
('user_appeals', 'gavel', 'User appeals for moderation decisions', false, false),
|
||||||
|
('user_violation_history', 'history', 'Daily violation history for users', false, false),
|
||||||
|
('appeal_guidelines', 'settings', 'Configurable appeal system guidelines', false, false)
|
||||||
|
ON CONFLICT (collection) DO NOTHING;
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
-- Updated User Appeal System Migration
|
||||||
|
-- Changes: More nuanced violation handling - content deletion + account marking instead of immediate bans
|
||||||
|
|
||||||
|
-- Update user_violations table to include content deletion tracking
|
||||||
|
ALTER TABLE user_violations ADD COLUMN IF NOT EXISTS content_deleted BOOLEAN DEFAULT false;
|
||||||
|
ALTER TABLE user_violations ADD COLUMN IF NOT EXISTS content_deletion_reason TEXT;
|
||||||
|
ALTER TABLE user_violations ADD COLUMN IF NOT EXISTS account_status_change VARCHAR(20) DEFAULT 'none';
|
||||||
|
|
||||||
|
-- Update user_violation_history table
|
||||||
|
ALTER TABLE user_violation_history ADD COLUMN IF NOT EXISTS content_deletions INTEGER DEFAULT 0;
|
||||||
|
ALTER TABLE user_violation_history ADD COLUMN IF NOT EXISTS account_warnings INTEGER DEFAULT 0;
|
||||||
|
ALTER TABLE user_violation_history ADD COLUMN IF NOT EXISTS account_suspensions INTEGER DEFAULT 0;
|
||||||
|
|
||||||
|
-- Update appeal guidelines to reflect new approach
|
||||||
|
UPDATE appeal_guidelines SET
|
||||||
|
auto_ban_threshold = 999, -- Disable auto-ban
|
||||||
|
hard_violation_ban_threshold = 999, -- Disable auto-ban
|
||||||
|
is_active = true
|
||||||
|
WHERE violation_type IN ('hard_violation', 'soft_violation');
|
||||||
|
|
||||||
|
-- Add new status options for user accounts
|
||||||
|
ALTER TABLE users ADD CONSTRAINT IF NOT EXISTS users_status_check
|
||||||
|
CHECK (status IN ('active', 'warning', 'suspended', 'banned', 'under_review'));
|
||||||
|
|
||||||
|
-- Update the ban checking function to use content deletion instead
|
||||||
|
CREATE OR REPLACE FUNCTION check_user_violation_status(p_user_id UUID) RETURNS TEXT AS $$
|
||||||
|
DECLARE
|
||||||
|
v_hard_count INTEGER;
|
||||||
|
v_total_count INTEGER;
|
||||||
|
v_soft_count INTEGER;
|
||||||
|
v_content_deletions INTEGER;
|
||||||
|
v_new_status TEXT := 'active';
|
||||||
|
BEGIN
|
||||||
|
-- Get counts from last 30 days
|
||||||
|
SELECT
|
||||||
|
COUNT(CASE WHEN violation_type = 'hard_violation' THEN 1 END),
|
||||||
|
COUNT(*),
|
||||||
|
COUNT(CASE WHEN violation_type = 'soft_violation' THEN 1 END),
|
||||||
|
COUNT(CASE WHEN content_deleted = true THEN 1 END)
|
||||||
|
INTO v_hard_count, v_total_count, v_soft_count, v_content_deletions
|
||||||
|
FROM user_violations
|
||||||
|
WHERE user_id = p_user_id
|
||||||
|
AND created_at >= NOW() - INTERVAL '30 days';
|
||||||
|
|
||||||
|
-- Determine account status based on violation patterns
|
||||||
|
IF v_hard_count >= 3 THEN
|
||||||
|
v_new_status := 'banned';
|
||||||
|
ELSIF v_total_count >= 8 THEN
|
||||||
|
v_new_status := 'banned';
|
||||||
|
ELSIF v_hard_count >= 2 THEN
|
||||||
|
v_new_status := 'suspended';
|
||||||
|
ELSIF v_total_count >= 5 THEN
|
||||||
|
v_new_status := 'suspended';
|
||||||
|
ELSIF v_hard_count >= 1 OR v_total_count >= 3 THEN
|
||||||
|
v_new_status := 'warning';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Update user status if it needs to change
|
||||||
|
IF v_new_status != 'active' THEN
|
||||||
|
UPDATE users
|
||||||
|
SET status = v_new_status, updated_at = NOW()
|
||||||
|
WHERE id = p_user_id AND status != v_new_status;
|
||||||
|
|
||||||
|
-- Update violation history
|
||||||
|
UPDATE user_violation_history
|
||||||
|
SET
|
||||||
|
current_status = v_new_status,
|
||||||
|
content_deletions = v_content_deletions,
|
||||||
|
account_warnings = CASE WHEN v_new_status = 'warning' THEN account_warnings + 1 ELSE account_warnings END,
|
||||||
|
account_suspensions = CASE WHEN v_new_status = 'suspended' THEN account_suspensions + 1 ELSE account_suspensions END,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE user_id = p_user_id AND violation_date = CURRENT_DATE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN v_new_status;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Create function to handle content deletion for violations
|
||||||
|
CREATE OR REPLACE FUNCTION handle_violation_content_deletion(
|
||||||
|
p_user_id UUID,
|
||||||
|
p_moderation_flag_id UUID,
|
||||||
|
p_violation_type TEXT
|
||||||
|
) RETURNS BOOLEAN AS $$
|
||||||
|
DECLARE
|
||||||
|
v_post_id UUID;
|
||||||
|
v_comment_id UUID;
|
||||||
|
v_content_deleted BOOLEAN := false;
|
||||||
|
BEGIN
|
||||||
|
-- Get the content ID from the moderation flag
|
||||||
|
SELECT post_id, comment_id
|
||||||
|
INTO v_post_id, v_comment_id
|
||||||
|
FROM moderation_flags
|
||||||
|
WHERE id = p_moderation_flag_id;
|
||||||
|
|
||||||
|
-- Delete or hide the content based on violation type
|
||||||
|
IF p_violation_type = 'hard_violation' THEN
|
||||||
|
-- Hard violations: delete content immediately
|
||||||
|
IF v_post_id IS NOT NULL THEN
|
||||||
|
UPDATE posts SET status = 'deleted', updated_at = NOW() WHERE id = v_post_id;
|
||||||
|
v_content_deleted := true;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_comment_id IS NOT NULL THEN
|
||||||
|
UPDATE comments SET status = 'deleted', updated_at = NOW() WHERE id = v_comment_id;
|
||||||
|
v_content_deleted := true;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Mark the violation record
|
||||||
|
UPDATE user_violations
|
||||||
|
SET content_deleted = true,
|
||||||
|
content_deletion_reason = 'Hard violation - immediate deletion',
|
||||||
|
account_status_change = CASE
|
||||||
|
WHEN (SELECT COUNT(*) FROM user_violations WHERE user_id = p_user_id AND violation_type = 'hard_violation') >= 2 THEN 'suspended'
|
||||||
|
WHEN (SELECT COUNT(*) FROM user_violations WHERE user_id = p_user_id AND violation_type = 'hard_violation') >= 1 THEN 'warning'
|
||||||
|
ELSE 'none'
|
||||||
|
END,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE moderation_flag_id = p_moderation_flag_id;
|
||||||
|
|
||||||
|
ELSIF p_violation_type = 'soft_violation' THEN
|
||||||
|
-- Soft violations: hide content pending appeal
|
||||||
|
IF v_post_id IS NOT NULL THEN
|
||||||
|
UPDATE posts SET status = 'pending_moderation', updated_at = NOW() WHERE id = v_post_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_comment_id IS NOT NULL THEN
|
||||||
|
UPDATE comments SET status = 'pending_moderation', updated_at = NOW() WHERE id = v_comment_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Mark the violation record
|
||||||
|
UPDATE user_violations
|
||||||
|
SET content_deleted = false,
|
||||||
|
content_deletion_reason = 'Soft violation - pending moderation',
|
||||||
|
account_status_change = 'none',
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE moderation_flag_id = p_moderation_flag_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Check if user status needs updating
|
||||||
|
PERFORM check_user_violation_status(p_user_id);
|
||||||
|
|
||||||
|
RETURN v_content_deleted;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Update the create_user_violation function to use the new content handling
|
||||||
|
CREATE OR REPLACE FUNCTION create_user_violation(
|
||||||
|
p_user_id UUID,
|
||||||
|
p_moderation_flag_id UUID,
|
||||||
|
p_flag_reason TEXT,
|
||||||
|
p_scores JSONB
|
||||||
|
) RETURNS UUID AS $$
|
||||||
|
DECLARE
|
||||||
|
v_violation_id UUID;
|
||||||
|
v_violation_type TEXT;
|
||||||
|
v_severity DECIMAL;
|
||||||
|
v_is_appealable BOOLEAN;
|
||||||
|
v_appeal_deadline TIMESTAMP WITH TIME ZONE;
|
||||||
|
BEGIN
|
||||||
|
-- Determine violation type based on scores and reason
|
||||||
|
CASE
|
||||||
|
WHEN p_flag_reason IN ('hate') AND (p_scores->>'hate')::DECIMAL > 0.8 THEN
|
||||||
|
BEGIN
|
||||||
|
v_violation_type := 'hard_violation';
|
||||||
|
v_severity := (p_scores->>'hate')::DECIMAL;
|
||||||
|
v_is_appealable := false;
|
||||||
|
v_appeal_deadline := NULL;
|
||||||
|
END;
|
||||||
|
WHEN p_flag_reason IN ('hate', 'violence', 'sexual') AND (p_scores->>'hate')::DECIMAL > 0.6 THEN
|
||||||
|
BEGIN
|
||||||
|
v_violation_type := 'hard_violation';
|
||||||
|
v_severity := GREATEST((p_scores->>'hate')::DECIMAL, (p_scores->>'greed')::DECIMAL, (p_scores->>'delusion')::DECIMAL);
|
||||||
|
v_is_appealable := false;
|
||||||
|
v_appeal_deadline := NULL;
|
||||||
|
END;
|
||||||
|
ELSE
|
||||||
|
BEGIN
|
||||||
|
v_violation_type := 'soft_violation';
|
||||||
|
v_severity := GREATEST((p_scores->>'hate')::DECIMAL, (p_scores->>'greed')::DECIMAL, (p_scores->>'delusion')::DECIMAL);
|
||||||
|
v_is_appealable := true;
|
||||||
|
v_appeal_deadline := NOW() + (SELECT appeal_window_hours FROM appeal_guidelines WHERE violation_type = 'soft_violation' AND is_active = true LIMIT 1) * INTERVAL '1 hour';
|
||||||
|
END;
|
||||||
|
END CASE;
|
||||||
|
|
||||||
|
-- Create the violation record
|
||||||
|
INSERT INTO user_violations (user_id, moderation_flag_id, violation_type, violation_reason, severity_score, is_appealable, appeal_deadline)
|
||||||
|
VALUES (p_user_id, p_moderation_flag_id, v_violation_type, p_flag_reason, v_severity, v_is_appealable, v_appeal_deadline)
|
||||||
|
RETURNING id INTO v_violation_id;
|
||||||
|
|
||||||
|
-- Update violation history
|
||||||
|
INSERT INTO user_violation_history (user_id, violation_date, total_violations, hard_violations, soft_violations)
|
||||||
|
VALUES (p_user_id, CURRENT_DATE, 1,
|
||||||
|
CASE WHEN v_violation_type = 'hard_violation' THEN 1 ELSE 0 END,
|
||||||
|
CASE WHEN v_violation_type = 'soft_violation' THEN 1 ELSE 0 END)
|
||||||
|
ON CONFLICT (user_id, violation_date)
|
||||||
|
DO UPDATE SET
|
||||||
|
total_violations = user_violation_history.total_violations + 1,
|
||||||
|
hard_violations = user_violation_history.hard_violations + CASE WHEN v_violation_type = 'hard_violation' THEN 1 ELSE 0 END,
|
||||||
|
soft_violations = user_violation_history.soft_violations + CASE WHEN v_violation_type = 'soft_violation' THEN 1 ELSE 0 END,
|
||||||
|
updated_at = NOW();
|
||||||
|
|
||||||
|
-- Handle content deletion based on violation type
|
||||||
|
PERFORM handle_violation_content_deletion(p_user_id, p_moderation_flag_id, v_violation_type);
|
||||||
|
|
||||||
|
RETURN v_violation_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
-- Add email preference columns to users table
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_newsletter BOOLEAN DEFAULT false;
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_contact BOOLEAN DEFAULT false;
|
||||||
|
|
||||||
|
-- Add indexes for performance if needed
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_email_newsletter ON users(email_newsletter);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_email_contact ON users(email_contact);
|
||||||
|
|
||||||
|
-- Add comment for documentation
|
||||||
|
COMMENT ON COLUMN users.email_newsletter IS 'User has opted in to receive newsletter emails';
|
||||||
|
COMMENT ON COLUMN users.email_contact IS 'User has opted in to receive contact/transactional emails';
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
-- Admin Panel Support Tables
|
||||||
|
|
||||||
|
-- Algorithm configuration (key-value store for feed/moderation tuning)
|
||||||
|
CREATE TABLE IF NOT EXISTS public.algorithm_config (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Seed default algorithm config values
|
||||||
|
INSERT INTO public.algorithm_config (key, value, description) VALUES
|
||||||
|
('feed_recency_weight', '0.4', 'Weight for post recency in feed ranking'),
|
||||||
|
('feed_engagement_weight', '0.3', 'Weight for engagement metrics (likes, comments)'),
|
||||||
|
('feed_harmony_weight', '0.2', 'Weight for author harmony/trust score'),
|
||||||
|
('feed_diversity_weight', '0.1', 'Weight for content diversity in feed'),
|
||||||
|
('moderation_auto_flag_threshold', '0.7', 'AI score threshold for auto-flagging content'),
|
||||||
|
('moderation_auto_remove_threshold', '0.95', 'AI score threshold for automatic content removal'),
|
||||||
|
('moderation_greed_keyword_threshold', '0.7', 'Keyword-based spam/greed detection threshold'),
|
||||||
|
('feed_max_posts_per_author', '3', 'Max posts from same author in a single feed page'),
|
||||||
|
('feed_boost_mutual_follow', '1.5', 'Multiplier boost for posts from mutual follows'),
|
||||||
|
('feed_beacon_boost', '1.2', 'Multiplier boost for beacon posts in nearby feeds')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
-- Audit log for admin actions
|
||||||
|
CREATE TABLE IF NOT EXISTS public.audit_log (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
actor_id UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
target_type TEXT NOT NULL, -- 'user', 'post', 'comment', 'appeal', 'report', 'config'
|
||||||
|
target_id UUID,
|
||||||
|
details TEXT, -- JSON string with action-specific details
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON public.audit_log(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_actor_id ON public.audit_log(actor_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON public.audit_log(action);
|
||||||
|
|
||||||
|
-- Ensure profiles.role column exists (may already exist from prior migrations)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'profiles' AND column_name = 'role'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.profiles ADD COLUMN role TEXT NOT NULL DEFAULT 'user';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Ensure profiles.is_verified column exists
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'profiles' AND column_name = 'is_verified'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.profiles ADD COLUMN is_verified BOOLEAN DEFAULT FALSE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Ensure profiles.is_private column exists
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'profiles' AND column_name = 'is_private'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.profiles ADD COLUMN is_private BOOLEAN DEFAULT FALSE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Ensure users.status column exists
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'status'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.users ADD COLUMN status TEXT NOT NULL DEFAULT 'active';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Ensure users.last_login column exists
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'last_login'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.users ADD COLUMN last_login TIMESTAMPTZ;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
-- Official account AI posting configurations
|
||||||
|
CREATE TABLE IF NOT EXISTS official_account_configs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
profile_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||||
|
account_type TEXT NOT NULL DEFAULT 'general', -- 'general', 'news', 'community', etc.
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
-- AI config
|
||||||
|
model_id TEXT NOT NULL DEFAULT 'google/gemini-2.0-flash-001',
|
||||||
|
system_prompt TEXT NOT NULL DEFAULT '',
|
||||||
|
temperature DOUBLE PRECISION NOT NULL DEFAULT 0.7,
|
||||||
|
max_tokens INTEGER NOT NULL DEFAULT 500,
|
||||||
|
|
||||||
|
-- Posting config
|
||||||
|
post_interval_minutes INTEGER NOT NULL DEFAULT 60,
|
||||||
|
max_posts_per_day INTEGER NOT NULL DEFAULT 24,
|
||||||
|
posts_today INTEGER NOT NULL DEFAULT 0,
|
||||||
|
posts_today_reset_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_posted_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- News-specific config (only for account_type = 'news')
|
||||||
|
news_sources JSONB NOT NULL DEFAULT '[]'::jsonb, -- array of {name, rss_url, enabled}
|
||||||
|
last_fetched_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT unique_profile_config UNIQUE (profile_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oac_profile ON official_account_configs(profile_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oac_enabled ON official_account_configs(enabled) WHERE enabled = true;
|
||||||
|
|
||||||
|
-- Track which news articles have already been posted to avoid duplicates
|
||||||
|
CREATE TABLE IF NOT EXISTS official_account_posted_articles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
config_id UUID NOT NULL REFERENCES official_account_configs(id) ON DELETE CASCADE,
|
||||||
|
article_url TEXT NOT NULL,
|
||||||
|
article_title TEXT NOT NULL DEFAULT '',
|
||||||
|
source_name TEXT NOT NULL DEFAULT '',
|
||||||
|
posted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
post_id UUID REFERENCES public.posts(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
CONSTRAINT unique_article_per_config UNIQUE (config_id, article_url)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oapa_config ON official_account_posted_articles(config_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oapa_url ON official_account_posted_articles(article_url);
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
-- Add link preview columns to posts table
|
||||||
|
ALTER TABLE public.posts
|
||||||
|
ADD COLUMN IF NOT EXISTS link_preview_url TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS link_preview_title TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS link_preview_description TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS link_preview_image_url TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS link_preview_site_name TEXT;
|
||||||
|
|
||||||
|
-- Index for quick lookups when enriching posts
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_link_preview ON public.posts (id) WHERE link_preview_url IS NOT NULL;
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
-- Safe domains table: approved domains for link previews and external link warnings
|
||||||
|
CREATE TABLE IF NOT EXISTS safe_domains (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
domain TEXT NOT NULL UNIQUE,
|
||||||
|
category TEXT NOT NULL DEFAULT 'general', -- general, news, social, government, education, etc.
|
||||||
|
is_approved BOOLEAN NOT NULL DEFAULT true, -- true = safe, false = explicitly blocked
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_safe_domains_domain ON safe_domains (domain);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_safe_domains_approved ON safe_domains (is_approved);
|
||||||
|
|
||||||
|
-- Seed with common safe domains
|
||||||
|
INSERT INTO safe_domains (domain, category, is_approved, notes) VALUES
|
||||||
|
-- News
|
||||||
|
('npr.org', 'news', true, 'National Public Radio'),
|
||||||
|
('apnews.com', 'news', true, 'Associated Press'),
|
||||||
|
('bringmethenews.com', 'news', true, 'Bring Me The News - Minnesota'),
|
||||||
|
('reuters.com', 'news', true, 'Reuters'),
|
||||||
|
('bbc.com', 'news', true, 'BBC'),
|
||||||
|
('bbc.co.uk', 'news', true, 'BBC UK'),
|
||||||
|
('nytimes.com', 'news', true, 'New York Times'),
|
||||||
|
('washingtonpost.com', 'news', true, 'Washington Post'),
|
||||||
|
('theguardian.com', 'news', true, 'The Guardian'),
|
||||||
|
('startribune.com', 'news', true, 'Star Tribune - Minnesota'),
|
||||||
|
('mprnews.org', 'news', true, 'MPR News - Minnesota'),
|
||||||
|
('kstp.com', 'news', true, 'KSTP - Minnesota'),
|
||||||
|
('kare11.com', 'news', true, 'KARE 11 - Minnesota'),
|
||||||
|
('fox9.com', 'news', true, 'Fox 9 - Minnesota'),
|
||||||
|
('wcco.com', 'news', true, 'WCCO - Minnesota'),
|
||||||
|
-- Social / Tech
|
||||||
|
('wikipedia.org', 'education', true, 'Wikipedia'),
|
||||||
|
('github.com', 'tech', true, 'GitHub'),
|
||||||
|
('youtube.com', 'social', true, 'YouTube'),
|
||||||
|
('youtu.be', 'social', true, 'YouTube short links'),
|
||||||
|
('vimeo.com', 'social', true, 'Vimeo'),
|
||||||
|
('spotify.com', 'social', true, 'Spotify'),
|
||||||
|
('open.spotify.com', 'social', true, 'Spotify Open'),
|
||||||
|
('soundcloud.com', 'social', true, 'SoundCloud'),
|
||||||
|
('twitch.tv', 'social', true, 'Twitch'),
|
||||||
|
('reddit.com', 'social', true, 'Reddit'),
|
||||||
|
('imgur.com', 'social', true, 'Imgur'),
|
||||||
|
-- Government
|
||||||
|
('gov', 'government', true, 'US Government domains'),
|
||||||
|
('state.mn.us', 'government', true, 'Minnesota State'),
|
||||||
|
('minneapolismn.gov', 'government', true, 'City of Minneapolis'),
|
||||||
|
('stpaul.gov', 'government', true, 'City of St. Paul'),
|
||||||
|
-- Sojorn
|
||||||
|
('sojorn.net', 'internal', true, 'Sojorn'),
|
||||||
|
('gosojorn.com', 'internal', true, 'Sojorn legacy')
|
||||||
|
ON CONFLICT (domain) DO NOTHING;
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
-- Article pipeline: track articles from discovery through posting
|
||||||
|
CREATE TABLE IF NOT EXISTS official_account_articles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
config_id UUID NOT NULL REFERENCES official_account_configs(id) ON DELETE CASCADE,
|
||||||
|
guid TEXT NOT NULL, -- unique article ID (Google News URL or RSS GUID)
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
link TEXT NOT NULL, -- resolved URL (what gets posted)
|
||||||
|
source_name TEXT NOT NULL DEFAULT '',
|
||||||
|
source_url TEXT NOT NULL DEFAULT '',
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
pub_date TIMESTAMPTZ,
|
||||||
|
status TEXT NOT NULL DEFAULT 'discovered', -- discovered | posted | failed | skipped
|
||||||
|
post_id UUID REFERENCES public.posts(id) ON DELETE SET NULL,
|
||||||
|
error_message TEXT,
|
||||||
|
discovered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
posted_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
CONSTRAINT unique_article_guid_per_config UNIQUE (config_id, guid)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oaa_config_status ON official_account_articles(config_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oaa_discovered ON official_account_articles(discovered_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oaa_guid ON official_account_articles(guid);
|
||||||
|
|
||||||
|
-- Migrate existing posted articles into the new table
|
||||||
|
INSERT INTO official_account_articles (config_id, guid, title, link, source_name, status, post_id, discovered_at, posted_at)
|
||||||
|
SELECT config_id, article_url, article_title, article_url, source_name, 'posted', post_id, posted_at, posted_at
|
||||||
|
FROM official_account_posted_articles
|
||||||
|
ON CONFLICT (config_id, guid) DO NOTHING;
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
-- Add category column to forum threads for sub-forums functionality
|
|
||||||
ALTER TABLE group_forum_threads ADD COLUMN IF NOT EXISTS category TEXT;
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_group_forum_threads_category ON group_forum_threads(group_id, category) WHERE is_deleted = FALSE;
|
|
||||||
|
|
@ -9,9 +9,9 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/config"
|
"github.com/patbritton/sojorn-backend/internal/config"
|
||||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/repository"
|
"github.com/patbritton/sojorn-backend/internal/repository"
|
||||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/services"
|
"github.com/patbritton/sojorn-backend/internal/services"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -196,7 +196,15 @@ func (h *AccountHandler) ConfirmImmediateDestroy(c *gin.Context) {
|
||||||
|
|
||||||
log.Warn().Str("user_id", userID).Msg("ACCOUNT DESTROYED — all data permanently purged")
|
log.Warn().Str("user_id", userID).Msg("ACCOUNT DESTROYED — all data permanently purged")
|
||||||
|
|
||||||
c.Redirect(http.StatusFound, h.config.AppBaseURL+"/destroyed")
|
// Return a simple HTML goodbye page (this is accessed via browser from email link)
|
||||||
|
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(`<!DOCTYPE html>
|
||||||
|
<html><head><meta charset="utf-8"><title>Account Destroyed</title>
|
||||||
|
<style>body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#1a1a2e;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;}
|
||||||
|
.card{max-width:480px;background:#16213e;border-radius:16px;padding:48px;text-align:center;}
|
||||||
|
h1{color:#dc2626;font-size:24px;}p{line-height:1.6;color:#a0a0b0;}</style></head>
|
||||||
|
<body><div class="card"><h1>Account Destroyed</h1>
|
||||||
|
<p>Your account and all associated data have been permanently destroyed. This action cannot be undone.</p>
|
||||||
|
<p style="margin-top:32px;color:#666;">Goodbye. — Sojorn</p></div></body></html>`))
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAccountStatus returns the current account lifecycle status
|
// GetAccountStatus returns the current account lifecycle status
|
||||||
|
|
@ -260,15 +268,174 @@ func generateDestroyToken() (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AccountHandler) sendDeactivationEmail(toEmail, toName string) error {
|
func (h *AccountHandler) sendDeactivationEmail(toEmail, toName string) error {
|
||||||
return h.emailService.SendDeactivationEmail(toEmail, toName)
|
subject := "Your Sojorn account has been deactivated"
|
||||||
|
|
||||||
|
htmlBody := fmt.Sprintf(`<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="utf-8"></head>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #1a1a2e; color: #e0e0e0; padding: 40px;">
|
||||||
|
<div style="max-width: 560px; margin: 0 auto; background: #16213e; border-radius: 16px; padding: 40px; border: 1px solid #334155;">
|
||||||
|
|
||||||
|
<h1 style="color: #f59e0b; font-size: 22px; margin: 0 0 24px 0;">Account Deactivated</h1>
|
||||||
|
|
||||||
|
<p style="font-size: 16px; line-height: 1.6;">Hey %s,</p>
|
||||||
|
|
||||||
|
<p style="font-size: 16px; line-height: 1.6;">Your Sojorn account has been deactivated. Your profile is now hidden from other users.</p>
|
||||||
|
|
||||||
|
<div style="background: #1e293b; border: 1px solid #475569; border-radius: 12px; padding: 20px; margin: 24px 0;">
|
||||||
|
<p style="color: #94a3b8; font-size: 14px; margin: 0 0 8px 0; font-weight: bold;">What this means:</p>
|
||||||
|
<ul style="color: #94a3b8; font-size: 14px; margin: 0; padding-left: 20px; line-height: 1.8;">
|
||||||
|
<li>Your profile, posts, and connections are hidden but <strong>fully preserved</strong></li>
|
||||||
|
<li>No one can see your account while it is deactivated</li>
|
||||||
|
<li>You can reactivate at any time simply by <strong>logging back in</strong></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-size: 14px; color: #888; line-height: 1.6;">If you did not request this, please log back in immediately to reactivate your account and secure it by changing your password.</p>
|
||||||
|
|
||||||
|
<hr style="border: none; border-top: 1px solid #333; margin: 32px 0;">
|
||||||
|
<p style="font-size: 12px; color: #666; text-align: center;">MPLS LLC · Sojorn · Minneapolis, MN</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`, toName)
|
||||||
|
|
||||||
|
textBody := fmt.Sprintf(`Account Deactivated
|
||||||
|
|
||||||
|
Hey %s,
|
||||||
|
|
||||||
|
Your Sojorn account has been deactivated. Your profile is now hidden from other users.
|
||||||
|
|
||||||
|
What this means:
|
||||||
|
- Your profile, posts, and connections are hidden but fully preserved
|
||||||
|
- No one can see your account while it is deactivated
|
||||||
|
- You can reactivate at any time simply by logging back in
|
||||||
|
|
||||||
|
If you did not request this, please log back in immediately to reactivate your account and change your password.
|
||||||
|
|
||||||
|
MPLS LLC - Sojorn - Minneapolis, MN`, toName)
|
||||||
|
|
||||||
|
return h.emailService.SendGenericEmail(toEmail, toName, subject, htmlBody, textBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AccountHandler) sendDeletionScheduledEmail(toEmail, toName, deletionDate string) error {
|
func (h *AccountHandler) sendDeletionScheduledEmail(toEmail, toName, deletionDate string) error {
|
||||||
return h.emailService.SendDeletionScheduledEmail(toEmail, toName, deletionDate)
|
subject := "Your Sojorn account is scheduled for deletion"
|
||||||
|
|
||||||
|
htmlBody := fmt.Sprintf(`<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="utf-8"></head>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #1a1a2e; color: #e0e0e0; padding: 40px;">
|
||||||
|
<div style="max-width: 560px; margin: 0 auto; background: #16213e; border-radius: 16px; padding: 40px; border: 2px solid #ef4444;">
|
||||||
|
|
||||||
|
<h1 style="color: #ef4444; font-size: 22px; margin: 0 0 24px 0;">Account Deletion Scheduled</h1>
|
||||||
|
|
||||||
|
<p style="font-size: 16px; line-height: 1.6;">Hey %s,</p>
|
||||||
|
|
||||||
|
<p style="font-size: 16px; line-height: 1.6;">Your Sojorn account has been scheduled for <strong>permanent deletion on %s</strong>.</p>
|
||||||
|
|
||||||
|
<div style="background: #2d0000; border: 1px solid #dc2626; border-radius: 12px; padding: 20px; margin: 24px 0;">
|
||||||
|
<p style="color: #fca5a5; font-size: 14px; font-weight: bold; margin: 0 0 8px 0;">What happens next:</p>
|
||||||
|
<ul style="color: #fca5a5; font-size: 14px; margin: 0; padding-left: 20px; line-height: 1.8;">
|
||||||
|
<li>Your account is immediately deactivated and hidden</li>
|
||||||
|
<li>On <strong>%s</strong>, all data will be permanently and irreversibly destroyed</li>
|
||||||
|
<li>This includes posts, messages, encryption keys, profile, followers, and your handle</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #1e293b; border: 1px solid #475569; border-radius: 12px; padding: 20px; margin: 24px 0;">
|
||||||
|
<p style="color: #94a3b8; font-size: 14px; font-weight: bold; margin: 0 0 8px 0;">Changed your mind?</p>
|
||||||
|
<p style="color: #94a3b8; font-size: 14px; margin: 0; line-height: 1.6;">Simply <strong>log back in</strong> before %s to cancel the deletion and reactivate your account. All your data is still intact during the grace period.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-size: 14px; color: #888; line-height: 1.6;">If you did not request this, please log back in immediately to cancel the deletion and secure your account.</p>
|
||||||
|
|
||||||
|
<hr style="border: none; border-top: 1px solid #333; margin: 32px 0;">
|
||||||
|
<p style="font-size: 12px; color: #666; text-align: center;">MPLS LLC · Sojorn · Minneapolis, MN</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`, toName, deletionDate, deletionDate, deletionDate)
|
||||||
|
|
||||||
|
textBody := fmt.Sprintf(`Account Deletion Scheduled
|
||||||
|
|
||||||
|
Hey %s,
|
||||||
|
|
||||||
|
Your Sojorn account has been scheduled for permanent deletion on %s.
|
||||||
|
|
||||||
|
What happens next:
|
||||||
|
- Your account is immediately deactivated and hidden
|
||||||
|
- On %s, all data will be permanently and irreversibly destroyed
|
||||||
|
- This includes posts, messages, encryption keys, profile, followers, and your handle
|
||||||
|
|
||||||
|
Changed your mind?
|
||||||
|
Simply log back in before %s to cancel the deletion and reactivate your account.
|
||||||
|
|
||||||
|
If you did not request this, please log back in immediately to cancel the deletion and change your password.
|
||||||
|
|
||||||
|
MPLS LLC - Sojorn - Minneapolis, MN`, toName, deletionDate, deletionDate, deletionDate)
|
||||||
|
|
||||||
|
return h.emailService.SendGenericEmail(toEmail, toName, subject, htmlBody, textBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AccountHandler) sendDestroyConfirmationEmail(toEmail, toName, token string) error {
|
func (h *AccountHandler) sendDestroyConfirmationEmail(toEmail, toName, token string) error {
|
||||||
|
subject := "FINAL WARNING: Confirm Permanent Account Destruction"
|
||||||
|
|
||||||
confirmURL := fmt.Sprintf("%s/api/v1/account/destroy/confirm?token=%s",
|
confirmURL := fmt.Sprintf("%s/api/v1/account/destroy/confirm?token=%s",
|
||||||
h.config.APIBaseURL, token)
|
h.config.APIBaseURL, token)
|
||||||
return h.emailService.SendDestroyConfirmationEmail(toEmail, toName, confirmURL)
|
|
||||||
|
htmlBody := fmt.Sprintf(`<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="utf-8"></head>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #1a1a2e; color: #e0e0e0; padding: 40px;">
|
||||||
|
<div style="max-width: 560px; margin: 0 auto; background: #16213e; border-radius: 16px; padding: 40px; border: 2px solid #dc2626;">
|
||||||
|
|
||||||
|
<h1 style="color: #dc2626; font-size: 24px; margin: 0 0 24px 0;">⚠ Account Destruction Confirmation</h1>
|
||||||
|
|
||||||
|
<p style="font-size: 16px; line-height: 1.6;">Hey %s,</p>
|
||||||
|
|
||||||
|
<p style="font-size: 16px; line-height: 1.6;">You requested <strong>immediate and permanent destruction</strong> of your Sojorn account.</p>
|
||||||
|
|
||||||
|
<div style="background: #2d0000; border: 1px solid #dc2626; border-radius: 12px; padding: 20px; margin: 24px 0;">
|
||||||
|
<p style="color: #fca5a5; font-size: 14px; font-weight: bold; margin: 0 0 8px 0;">THIS ACTION IS IRREVERSIBLE</p>
|
||||||
|
<ul style="color: #fca5a5; font-size: 14px; margin: 0; padding-left: 20px; line-height: 1.8;">
|
||||||
|
<li>All your posts, comments, and media will be permanently deleted</li>
|
||||||
|
<li>All your messages and encryption keys will be destroyed</li>
|
||||||
|
<li>Your profile, followers, and social connections will be erased</li>
|
||||||
|
<li>Your handle will be released and cannot be reclaimed</li>
|
||||||
|
<li><strong>There is no recovery. No backup. No undo.</strong></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-size: 16px; line-height: 1.6;">If you are absolutely certain, click the button below. <strong>Your account will be destroyed the instant you click.</strong></p>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 32px 0;">
|
||||||
|
<a href="%s" style="background: #dc2626; color: white; padding: 16px 40px; border-radius: 8px; text-decoration: none; font-weight: bold; font-size: 16px; display: inline-block;">PERMANENTLY DESTROY MY ACCOUNT</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-size: 13px; color: #888; line-height: 1.6;">This link expires in 1 hour. If you did not request this, you can safely ignore this email — your account will not be affected.</p>
|
||||||
|
|
||||||
|
<hr style="border: none; border-top: 1px solid #333; margin: 32px 0;">
|
||||||
|
<p style="font-size: 12px; color: #666; text-align: center;">MPLS LLC · Sojorn · Minneapolis, MN</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`, toName, confirmURL)
|
||||||
|
|
||||||
|
textBody := fmt.Sprintf(`FINAL WARNING: Confirm Permanent Account Destruction
|
||||||
|
|
||||||
|
Hey %s,
|
||||||
|
|
||||||
|
You requested IMMEDIATE AND PERMANENT DESTRUCTION of your Sojorn account.
|
||||||
|
|
||||||
|
THIS ACTION IS IRREVERSIBLE:
|
||||||
|
- All posts, comments, and media permanently deleted
|
||||||
|
- All messages and encryption keys destroyed
|
||||||
|
- Profile, followers, and connections erased
|
||||||
|
- Handle released and cannot be reclaimed
|
||||||
|
- THERE IS NO RECOVERY. NO BACKUP. NO UNDO.
|
||||||
|
|
||||||
|
To confirm, visit: %s
|
||||||
|
|
||||||
|
This link expires in 1 hour. If you did not request this, ignore this email.
|
||||||
|
|
||||||
|
MPLS LLC - Sojorn - Minneapolis, MN`, toName, confirmURL)
|
||||||
|
|
||||||
|
return h.emailService.SendGenericEmail(toEmail, toName, subject, htmlBody, textBody)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -18,7 +15,7 @@ import (
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/services"
|
"github.com/patbritton/sojorn-backend/internal/services"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
@ -29,10 +26,8 @@ type AdminHandler struct {
|
||||||
appealService *services.AppealService
|
appealService *services.AppealService
|
||||||
emailService *services.EmailService
|
emailService *services.EmailService
|
||||||
openRouterService *services.OpenRouterService
|
openRouterService *services.OpenRouterService
|
||||||
azureOpenAIService *services.AzureOpenAIService
|
|
||||||
officialAccountsService *services.OfficialAccountsService
|
officialAccountsService *services.OfficialAccountsService
|
||||||
linkPreviewService *services.LinkPreviewService
|
linkPreviewService *services.LinkPreviewService
|
||||||
localAIService *services.LocalAIService
|
|
||||||
jwtSecret string
|
jwtSecret string
|
||||||
turnstileSecret string
|
turnstileSecret string
|
||||||
s3Client *s3.Client
|
s3Client *s3.Client
|
||||||
|
|
@ -42,17 +37,15 @@ type AdminHandler struct {
|
||||||
vidDomain string
|
vidDomain string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationService, appealService *services.AppealService, emailService *services.EmailService, openRouterService *services.OpenRouterService, azureOpenAIService *services.AzureOpenAIService, officialAccountsService *services.OfficialAccountsService, linkPreviewService *services.LinkPreviewService, localAIService *services.LocalAIService, jwtSecret string, turnstileSecret string, s3Client *s3.Client, mediaBucket string, videoBucket string, imgDomain string, vidDomain string) *AdminHandler {
|
func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationService, appealService *services.AppealService, emailService *services.EmailService, openRouterService *services.OpenRouterService, officialAccountsService *services.OfficialAccountsService, linkPreviewService *services.LinkPreviewService, jwtSecret string, turnstileSecret string, s3Client *s3.Client, mediaBucket string, videoBucket string, imgDomain string, vidDomain string) *AdminHandler {
|
||||||
return &AdminHandler{
|
return &AdminHandler{
|
||||||
pool: pool,
|
pool: pool,
|
||||||
moderationService: moderationService,
|
moderationService: moderationService,
|
||||||
appealService: appealService,
|
appealService: appealService,
|
||||||
emailService: emailService,
|
emailService: emailService,
|
||||||
openRouterService: openRouterService,
|
openRouterService: openRouterService,
|
||||||
azureOpenAIService: azureOpenAIService,
|
|
||||||
officialAccountsService: officialAccountsService,
|
officialAccountsService: officialAccountsService,
|
||||||
linkPreviewService: linkPreviewService,
|
linkPreviewService: linkPreviewService,
|
||||||
localAIService: localAIService,
|
|
||||||
jwtSecret: jwtSecret,
|
jwtSecret: jwtSecret,
|
||||||
turnstileSecret: turnstileSecret,
|
turnstileSecret: turnstileSecret,
|
||||||
s3Client: s3Client,
|
s3Client: s3Client,
|
||||||
|
|
@ -64,46 +57,44 @@ func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationS
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
// Admin Login (invisible ALTCHA verification)
|
// Admin Login (invisible Turnstile verification)
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
type AdminLoginRequest struct {
|
|
||||||
Email string `json:"email" binding:"required,email"`
|
|
||||||
Password string `json:"password" binding:"required"`
|
|
||||||
AltchaToken string `json:"altcha_token"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AdminHandler) AdminLogin(c *gin.Context) {
|
func (h *AdminHandler) AdminLogin(c *gin.Context) {
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
var req AdminLoginRequest
|
var req struct {
|
||||||
|
Email string `json:"email" binding:"required,email"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
TurnstileToken string `json:"turnstile_token"`
|
||||||
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||||
|
|
||||||
// Verify ALTCHA token
|
// Verify Turnstile token
|
||||||
altchaService := services.NewAltchaService(h.jwtSecret)
|
if h.turnstileSecret != "" {
|
||||||
|
turnstileService := services.NewTurnstileService(h.turnstileSecret)
|
||||||
remoteIP := c.ClientIP()
|
remoteIP := c.ClientIP()
|
||||||
altchaResp, err := altchaService.VerifyToken(req.AltchaToken, remoteIP)
|
turnstileResp, err := turnstileService.VerifyToken(req.TurnstileToken, remoteIP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("Admin login: ALTCHA verification failed")
|
log.Error().Err(err).Msg("Admin login: Turnstile verification failed")
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Security verification failed"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Security verification failed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !turnstileResp.Success {
|
||||||
if !altchaResp.Verified {
|
log.Warn().Strs("errors", turnstileResp.ErrorCodes).Msg("Admin login: Turnstile validation failed")
|
||||||
errorMsg := altchaService.GetErrorMessage(altchaResp.Error)
|
c.JSON(http.StatusForbidden, gin.H{"error": "Security verification failed. Please try again."})
|
||||||
log.Warn().Str("email", req.Email).Str("error", errorMsg).Msg("Admin login: ALTCHA validation failed")
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": errorMsg})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Look up user
|
// Look up user
|
||||||
var userID uuid.UUID
|
var userID uuid.UUID
|
||||||
var passwordHash, status string
|
var passwordHash, status string
|
||||||
err = h.pool.QueryRow(ctx,
|
err := h.pool.QueryRow(ctx,
|
||||||
`SELECT id, encrypted_password, COALESCE(status, 'active') FROM users WHERE email = $1 AND deleted_at IS NULL`,
|
`SELECT id, encrypted_password, COALESCE(status, 'active') FROM users WHERE email = $1 AND deleted_at IS NULL`,
|
||||||
req.Email).Scan(&userID, &passwordHash, &status)
|
req.Email).Scan(&userID, &passwordHash, &status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1065,7 +1056,7 @@ func (h *AdminHandler) UpdatePostStatus(c *gin.Context) {
|
||||||
// Send email notification
|
// Send email notification
|
||||||
if h.emailService != nil && authorEmail != "" {
|
if h.emailService != nil && authorEmail != "" {
|
||||||
go func() {
|
go func() {
|
||||||
if err := h.emailService.SendContentRemovalEmail(authorEmail, displayName, "post", reason, strikeCount); err != nil {
|
if err := h.emailService.SendPostRemovalEmail(authorEmail, displayName, reason, strikeCount); err != nil {
|
||||||
log.Error().Err(err).Str("user", authorID.String()).Msg("Failed to send post removal email")
|
log.Error().Err(err).Str("user", authorID.String()).Msg("Failed to send post removal email")
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
@ -1120,7 +1111,7 @@ func (h *AdminHandler) DeletePost(c *gin.Context) {
|
||||||
|
|
||||||
if h.emailService != nil && authorEmail != "" {
|
if h.emailService != nil && authorEmail != "" {
|
||||||
go func() {
|
go func() {
|
||||||
if err := h.emailService.SendContentRemovalEmail(authorEmail, displayName, "post", reason, strikeCount); err != nil {
|
if err := h.emailService.SendPostRemovalEmail(authorEmail, displayName, reason, strikeCount); err != nil {
|
||||||
log.Error().Err(err).Str("user", authorID.String()).Msg("Failed to send post removal email")
|
log.Error().Err(err).Str("user", authorID.String()).Msg("Failed to send post removal email")
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
@ -2593,55 +2584,6 @@ func (h *AdminHandler) ListOpenRouterModels(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"models": models, "total": len(models)})
|
c.JSON(http.StatusOK, gin.H{"models": models, "total": len(models)})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListLocalModels returns models available on the local Ollama instance.
|
|
||||||
func (h *AdminHandler) ListLocalModels(c *gin.Context) {
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
// Query Ollama's /api/tags endpoint for locally available models
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", "http://localhost:11434/api/tags", nil)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create request"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 5 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Ollama not reachable", "models": []any{}})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var result struct {
|
|
||||||
Models []struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Model string `json:"model"`
|
|
||||||
ModifiedAt string `json:"modified_at"`
|
|
||||||
Size int64 `json:"size"`
|
|
||||||
} `json:"models"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse Ollama response"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
type localModel struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Size int64 `json:"size"`
|
|
||||||
}
|
|
||||||
models := make([]localModel, 0, len(result.Models))
|
|
||||||
for _, m := range result.Models {
|
|
||||||
models = append(models, localModel{
|
|
||||||
ID: m.Name,
|
|
||||||
Name: m.Name,
|
|
||||||
Size: m.Size,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"models": models, "total": len(models)})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AdminHandler) GetAIModerationConfigs(c *gin.Context) {
|
func (h *AdminHandler) GetAIModerationConfigs(c *gin.Context) {
|
||||||
if h.openRouterService == nil {
|
if h.openRouterService == nil {
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OpenRouter not configured"})
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OpenRouter not configured"})
|
||||||
|
|
@ -2669,21 +2611,19 @@ func (h *AdminHandler) SetAIModerationConfig(c *gin.Context) {
|
||||||
ModelName string `json:"model_name"`
|
ModelName string `json:"model_name"`
|
||||||
SystemPrompt string `json:"system_prompt"`
|
SystemPrompt string `json:"system_prompt"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Engines []string `json:"engines"`
|
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
allowedTypes := map[string]bool{"text": true, "image": true, "video": true, "group_text": true, "group_image": true, "beacon_text": true, "beacon_image": true}
|
if req.ModerationType != "text" && req.ModerationType != "image" && req.ModerationType != "video" {
|
||||||
if !allowedTypes[req.ModerationType] {
|
c.JSON(http.StatusBadRequest, gin.H{"error": "moderation_type must be text, image, or video"})
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid moderation_type"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
adminID := c.GetString("user_id")
|
adminID := c.GetString("user_id")
|
||||||
err := h.openRouterService.SetModerationConfig(c.Request.Context(), req.ModerationType, req.ModelID, req.ModelName, req.SystemPrompt, req.Enabled, req.Engines, adminID)
|
err := h.openRouterService.SetModerationConfig(c.Request.Context(), req.ModerationType, req.ModelID, req.ModelName, req.SystemPrompt, req.Enabled, adminID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to save config: %v", err)})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to save config: %v", err)})
|
||||||
return
|
return
|
||||||
|
|
@ -2699,200 +2639,44 @@ func (h *AdminHandler) SetAIModerationConfig(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AdminHandler) TestAIModeration(c *gin.Context) {
|
func (h *AdminHandler) TestAIModeration(c *gin.Context) {
|
||||||
|
if h.openRouterService == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OpenRouter not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
ModerationType string `json:"moderation_type" binding:"required"`
|
ModerationType string `json:"moderation_type" binding:"required"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
ImageURL string `json:"image_url"`
|
ImageURL string `json:"image_url"`
|
||||||
Engine string `json:"engine"` // "local_ai", "openrouter", "openai" — empty = openrouter
|
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
engine := req.Engine
|
|
||||||
if engine == "" {
|
|
||||||
engine = "openrouter"
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
// Determine the input text for display
|
|
||||||
inputDisplay := req.Content
|
|
||||||
if inputDisplay == "" && req.ImageURL != "" {
|
|
||||||
inputDisplay = req.ImageURL
|
|
||||||
}
|
|
||||||
|
|
||||||
response := gin.H{
|
|
||||||
"engine": engine,
|
|
||||||
"moderation_type": req.ModerationType,
|
|
||||||
"input": inputDisplay,
|
|
||||||
}
|
|
||||||
|
|
||||||
switch engine {
|
|
||||||
case "local_ai":
|
|
||||||
if h.localAIService == nil {
|
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Local AI not configured"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Local AI only does text moderation
|
|
||||||
content := req.Content
|
|
||||||
if content == "" {
|
|
||||||
content = req.ImageURL // fallback
|
|
||||||
}
|
|
||||||
localResult, err := h.localAIService.ModerateText(ctx, content)
|
|
||||||
if err != nil {
|
|
||||||
response["error"] = err.Error()
|
|
||||||
c.JSON(http.StatusOK, response)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
action := "clean"
|
|
||||||
flagged := false
|
|
||||||
if !localResult.Allowed {
|
|
||||||
action = "flag"
|
|
||||||
flagged = true
|
|
||||||
}
|
|
||||||
explanation := "Local AI (llama-guard) determined this content is safe."
|
|
||||||
if flagged {
|
|
||||||
explanation = fmt.Sprintf("Local AI (llama-guard) flagged this content. Categories: %v, Severity: %s", localResult.Categories, localResult.Severity)
|
|
||||||
}
|
|
||||||
response["result"] = gin.H{
|
|
||||||
"action": action,
|
|
||||||
"flagged": flagged,
|
|
||||||
"reason": localResult.Reason,
|
|
||||||
"categories": localResult.Categories,
|
|
||||||
"explanation": explanation,
|
|
||||||
"raw_content": fmt.Sprintf("allowed=%v cached=%v categories=%v severity=%s reason=%s", localResult.Allowed, localResult.Cached, localResult.Categories, localResult.Severity, localResult.Reason),
|
|
||||||
}
|
|
||||||
|
|
||||||
case "openai":
|
|
||||||
if h.moderationService == nil {
|
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OpenAI moderation service not configured"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
content := req.Content
|
|
||||||
if content == "" {
|
|
||||||
content = req.ImageURL
|
|
||||||
}
|
|
||||||
mediaURLs := []string{}
|
|
||||||
if req.ImageURL != "" {
|
|
||||||
mediaURLs = append(mediaURLs, req.ImageURL)
|
|
||||||
}
|
|
||||||
scores, reason, err := h.moderationService.AnalyzeContent(ctx, content, mediaURLs)
|
|
||||||
if err != nil {
|
|
||||||
response["error"] = err.Error()
|
|
||||||
c.JSON(http.StatusOK, response)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
action := "clean"
|
|
||||||
flagged := false
|
|
||||||
if reason != "" {
|
|
||||||
action = "flag"
|
|
||||||
flagged = true
|
|
||||||
}
|
|
||||||
response["result"] = gin.H{
|
|
||||||
"action": action,
|
|
||||||
"flagged": flagged,
|
|
||||||
"reason": reason,
|
|
||||||
"hate": scores.Hate,
|
|
||||||
"greed": scores.Greed,
|
|
||||||
"delusion": scores.Delusion,
|
|
||||||
"hate_detail": fmt.Sprintf("Hate=%.3f", scores.Hate),
|
|
||||||
"greed_detail": fmt.Sprintf("Greed=%.3f", scores.Greed),
|
|
||||||
"delusion_detail": fmt.Sprintf("Delusion=%.3f", scores.Delusion),
|
|
||||||
"explanation": fmt.Sprintf("OpenAI Three Poisons analysis. Hate=%.3f, Greed=%.3f, Delusion=%.3f. %s", scores.Hate, scores.Greed, scores.Delusion, reason),
|
|
||||||
"raw_content": fmt.Sprintf("Hate=%.4f Greed=%.4f Delusion=%.4f", scores.Hate, scores.Greed, scores.Delusion),
|
|
||||||
}
|
|
||||||
|
|
||||||
case "google":
|
|
||||||
if h.moderationService == nil || !h.moderationService.HasGoogleVision() {
|
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Google Vision not configured"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
imageURL := req.ImageURL
|
|
||||||
if imageURL == "" {
|
|
||||||
// If they passed text content as a URL
|
|
||||||
imageURL = req.Content
|
|
||||||
}
|
|
||||||
if imageURL == "" {
|
|
||||||
response["error"] = "Image URL required for Google Vision test"
|
|
||||||
c.JSON(http.StatusOK, response)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
gResult, err := h.moderationService.AnalyzeImageWithGoogleVision(ctx, imageURL)
|
|
||||||
if err != nil {
|
|
||||||
response["error"] = err.Error()
|
|
||||||
c.JSON(http.StatusOK, response)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response["result"] = gResult
|
|
||||||
|
|
||||||
case "openrouter":
|
|
||||||
if h.openRouterService == nil {
|
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OpenRouter not configured"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var result *services.ModerationResult
|
var result *services.ModerationResult
|
||||||
var err error
|
var err error
|
||||||
isImage := strings.Contains(req.ModerationType, "image") || req.ModerationType == "video"
|
|
||||||
if isImage && req.ImageURL != "" {
|
switch req.ModerationType {
|
||||||
if req.ModerationType == "video" {
|
case "text":
|
||||||
|
result, err = h.openRouterService.ModerateText(ctx, req.Content)
|
||||||
|
case "image":
|
||||||
|
result, err = h.openRouterService.ModerateImage(ctx, req.ImageURL)
|
||||||
|
case "video":
|
||||||
urls := strings.Split(req.ImageURL, ",")
|
urls := strings.Split(req.ImageURL, ",")
|
||||||
result, err = h.openRouterService.ModerateVideo(ctx, urls)
|
result, err = h.openRouterService.ModerateVideo(ctx, urls)
|
||||||
} else {
|
|
||||||
result, err = h.openRouterService.ModerateImage(ctx, req.ImageURL)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Use type-specific config if available, fall back to generic text
|
|
||||||
result, err = h.openRouterService.ModerateWithType(ctx, req.ModerationType, req.Content, nil)
|
|
||||||
if err != nil {
|
|
||||||
// Fall back to generic text moderation
|
|
||||||
result, err = h.openRouterService.ModerateText(ctx, req.Content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
response["error"] = err.Error()
|
|
||||||
c.JSON(http.StatusOK, response)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response["result"] = result
|
|
||||||
|
|
||||||
case "azure":
|
|
||||||
if h.azureOpenAIService == nil {
|
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Azure OpenAI not configured"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var result *services.ModerationResult
|
|
||||||
var err error
|
|
||||||
isImage := strings.Contains(req.ModerationType, "image") || req.ModerationType == "video"
|
|
||||||
if isImage && req.ImageURL != "" {
|
|
||||||
if req.ModerationType == "video" {
|
|
||||||
urls := strings.Split(req.ImageURL, ",")
|
|
||||||
result, err = h.azureOpenAIService.ModerateVideo(ctx, urls)
|
|
||||||
} else {
|
|
||||||
result, err = h.azureOpenAIService.ModerateImage(ctx, req.ImageURL)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Use type-specific config if available, fall back to generic text
|
|
||||||
result, err = h.azureOpenAIService.ModerateWithType(ctx, req.ModerationType, req.Content, nil)
|
|
||||||
if err != nil {
|
|
||||||
// Fall back to generic text moderation
|
|
||||||
result, err = h.azureOpenAIService.ModerateText(ctx, req.Content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
response["error"] = err.Error()
|
|
||||||
c.JSON(http.StatusOK, response)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response["result"] = result
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid engine: " + engine})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid moderation_type"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, response)
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"result": result})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
|
|
@ -3547,22 +3331,39 @@ func (h *AdminHandler) PreviewOfficialPost(c *gin.Context) {
|
||||||
// Fetch available news articles (without posting)
|
// Fetch available news articles (without posting)
|
||||||
func (h *AdminHandler) FetchNewsArticles(c *gin.Context) {
|
func (h *AdminHandler) FetchNewsArticles(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
ctx := c.Request.Context()
|
items, sourceNames, err := h.officialAccountsService.FetchNewArticles(c.Request.Context(), id)
|
||||||
|
|
||||||
// Discover new articles first
|
|
||||||
_, _ = h.officialAccountsService.DiscoverArticles(ctx, id)
|
|
||||||
|
|
||||||
// Return the full CachedArticle objects so frontend has IDs for per-article actions
|
|
||||||
articles, err := h.officialAccountsService.GetArticleQueue(ctx, id, "discovered", 100)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if articles == nil {
|
|
||||||
articles = []services.CachedArticle{}
|
type articlePreview struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
PubDate string `json:"pub_date"`
|
||||||
}
|
}
|
||||||
stats, _ := h.officialAccountsService.GetArticleStats(ctx, id)
|
|
||||||
c.JSON(http.StatusOK, gin.H{"articles": articles, "count": len(articles), "stats": stats})
|
var previews []articlePreview
|
||||||
|
for i, item := range items {
|
||||||
|
desc := services.StripHTMLTagsPublic(item.Description)
|
||||||
|
if len(desc) > 200 {
|
||||||
|
desc = desc[:200] + "..."
|
||||||
|
}
|
||||||
|
previews = append(previews, articlePreview{
|
||||||
|
Title: item.Title,
|
||||||
|
Link: item.Link,
|
||||||
|
Description: desc,
|
||||||
|
Source: sourceNames[i],
|
||||||
|
PubDate: item.PubDate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if previews == nil {
|
||||||
|
previews = []articlePreview{}
|
||||||
|
}
|
||||||
|
stats, _ := h.officialAccountsService.GetArticleStats(c.Request.Context(), id)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"articles": previews, "count": len(previews), "stats": stats})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get articles by status for an account (defaults to "posted")
|
// Get articles by status for an account (defaults to "posted")
|
||||||
|
|
@ -3588,79 +3389,6 @@ func (h *AdminHandler) GetPostedArticles(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"articles": articles, "stats": stats})
|
c.JSON(http.StatusOK, gin.H{"articles": articles, "stats": stats})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Article Pipeline Management ──────────────────────
|
|
||||||
|
|
||||||
// SkipArticle marks a pending article as skipped.
|
|
||||||
func (h *AdminHandler) SkipArticle(c *gin.Context) {
|
|
||||||
articleID := c.Param("article_id")
|
|
||||||
if err := h.officialAccountsService.SkipArticle(c.Request.Context(), articleID); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Article skipped"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteArticle permanently removes an article from the pipeline.
|
|
||||||
func (h *AdminHandler) DeleteArticle(c *gin.Context) {
|
|
||||||
articleID := c.Param("article_id")
|
|
||||||
if err := h.officialAccountsService.DeleteArticle(c.Request.Context(), articleID); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Article deleted"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// PostSpecificArticle posts a single pending article by its ID.
|
|
||||||
func (h *AdminHandler) PostSpecificArticle(c *gin.Context) {
|
|
||||||
articleID := c.Param("article_id")
|
|
||||||
article, postID, err := h.officialAccountsService.PostSpecificArticle(c.Request.Context(), articleID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"message": "Article posted",
|
|
||||||
"post_id": postID,
|
|
||||||
"title": article.Title,
|
|
||||||
"link": article.Link,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// CleanupPendingArticles skips or deletes all pending articles older than a date.
|
|
||||||
func (h *AdminHandler) CleanupPendingArticles(c *gin.Context) {
|
|
||||||
configID := c.Param("id")
|
|
||||||
var req struct {
|
|
||||||
Before string `json:"before" binding:"required"` // ISO date: 2026-02-10
|
|
||||||
Action string `json:"action" binding:"required"` // "skip" or "delete"
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
before, err := time.Parse("2006-01-02", req.Before)
|
|
||||||
if err != nil {
|
|
||||||
before, err = time.Parse(time.RFC3339, req.Before)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid date format. Use YYYY-MM-DD or RFC3339."})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
affected, err := h.officialAccountsService.CleanupPendingByDate(c.Request.Context(), configID, before, req.Action)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
stats, _ := h.officialAccountsService.GetArticleStats(c.Request.Context(), configID)
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"message": fmt.Sprintf("%d article(s) %sed", affected, req.Action),
|
|
||||||
"affected": affected,
|
|
||||||
"stats": stats,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Safe Domains Management ─────────────────────────
|
// ── Safe Domains Management ─────────────────────────
|
||||||
|
|
||||||
func (h *AdminHandler) ListSafeDomains(c *gin.Context) {
|
func (h *AdminHandler) ListSafeDomains(c *gin.Context) {
|
||||||
|
|
@ -3713,170 +3441,6 @@ func (h *AdminHandler) DeleteSafeDomain(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAIEngines returns the status of all moderation engines (local AI, OpenRouter, OpenAI).
|
|
||||||
func (h *AdminHandler) GetAIEngines(c *gin.Context) {
|
|
||||||
type EngineStatus struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Configured bool `json:"configured"`
|
|
||||||
Details any `json:"details,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
engines := []EngineStatus{}
|
|
||||||
|
|
||||||
// 1. Local AI (Ollama via AI Gateway)
|
|
||||||
localEngine := EngineStatus{
|
|
||||||
ID: "local_ai",
|
|
||||||
Name: "Local AI (Ollama)",
|
|
||||||
Description: "On-server moderation via llama-guard3:1b + content generation via qwen2.5:7b. Free, private, ~2s latency.",
|
|
||||||
Configured: h.localAIService != nil,
|
|
||||||
}
|
|
||||||
if h.localAIService != nil {
|
|
||||||
health, err := h.localAIService.Healthz(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
localEngine.Status = "down"
|
|
||||||
localEngine.Details = map[string]string{"error": err.Error()}
|
|
||||||
} else {
|
|
||||||
localEngine.Status = health.Status
|
|
||||||
localEngine.Details = health
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
localEngine.Status = "not_configured"
|
|
||||||
}
|
|
||||||
engines = append(engines, localEngine)
|
|
||||||
|
|
||||||
// 2. OpenRouter
|
|
||||||
openRouterEngine := EngineStatus{
|
|
||||||
ID: "openrouter",
|
|
||||||
Name: "OpenRouter",
|
|
||||||
Description: "Cloud AI moderation via configurable models. Supports text, image, and video moderation with customizable system prompts.",
|
|
||||||
Configured: h.openRouterService != nil,
|
|
||||||
}
|
|
||||||
if h.openRouterService != nil {
|
|
||||||
configs, err := h.openRouterService.GetModerationConfigs(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
openRouterEngine.Status = "error"
|
|
||||||
} else {
|
|
||||||
enabledCount := 0
|
|
||||||
for _, cfg := range configs {
|
|
||||||
if cfg.Enabled {
|
|
||||||
enabledCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
openRouterEngine.Status = "ready"
|
|
||||||
openRouterEngine.Details = map[string]any{
|
|
||||||
"total_configs": len(configs),
|
|
||||||
"enabled_configs": enabledCount,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
openRouterEngine.Status = "not_configured"
|
|
||||||
}
|
|
||||||
engines = append(engines, openRouterEngine)
|
|
||||||
|
|
||||||
// 3. OpenAI Moderation (Three Poisons)
|
|
||||||
openAIEngine := EngineStatus{
|
|
||||||
ID: "openai",
|
|
||||||
Name: "OpenAI Moderation (Three Poisons)",
|
|
||||||
Description: "OpenAI content moderation API with Three Poisons scoring (Hate, Greed, Delusion). Used for posts and comments.",
|
|
||||||
Configured: h.moderationService != nil,
|
|
||||||
}
|
|
||||||
if h.moderationService != nil {
|
|
||||||
openAIEngine.Status = "ready"
|
|
||||||
} else {
|
|
||||||
openAIEngine.Status = "not_configured"
|
|
||||||
}
|
|
||||||
engines = append(engines, openAIEngine)
|
|
||||||
|
|
||||||
// 4. Google Vision (SafeSearch)
|
|
||||||
googleEngine := EngineStatus{
|
|
||||||
ID: "google",
|
|
||||||
Name: "Google Vision (SafeSearch)",
|
|
||||||
Description: "Google Cloud Vision SafeSearch detection for images. Returns Adult, Violence, Racy, Spoof, Medical likelihoods.",
|
|
||||||
Configured: h.moderationService != nil && h.moderationService.HasGoogleVision(),
|
|
||||||
}
|
|
||||||
if h.moderationService != nil && h.moderationService.HasGoogleVision() {
|
|
||||||
googleEngine.Status = "ready"
|
|
||||||
} else {
|
|
||||||
googleEngine.Status = "not_configured"
|
|
||||||
}
|
|
||||||
engines = append(engines, googleEngine)
|
|
||||||
|
|
||||||
// 5. Azure OpenAI
|
|
||||||
azureEngine := EngineStatus{
|
|
||||||
ID: "azure",
|
|
||||||
Name: "Azure OpenAI",
|
|
||||||
Description: "Microsoft Azure OpenAI service with vision models. Uses your Azure credits. Supports text and image moderation with customizable prompts.",
|
|
||||||
Configured: h.azureOpenAIService != nil,
|
|
||||||
}
|
|
||||||
if h.azureOpenAIService != nil {
|
|
||||||
azureEngine.Status = "ready"
|
|
||||||
azureEngine.Details = map[string]any{
|
|
||||||
"uses_azure_credits": true,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
azureEngine.Status = "not_configured"
|
|
||||||
}
|
|
||||||
engines = append(engines, azureEngine)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"engines": engines})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AdminHandler) UploadTestImage(c *gin.Context) {
|
|
||||||
file, header, err := c.Request.FormFile("file")
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No file uploaded"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
// Validate file type
|
|
||||||
if !strings.HasPrefix(header.Header.Get("Content-Type"), "image/") {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Only image files are allowed"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file size (5MB limit)
|
|
||||||
if header.Size > 5*1024*1024 {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "File too large (max 5MB)"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate unique filename
|
|
||||||
ext := filepath.Ext(header.Filename)
|
|
||||||
filename := fmt.Sprintf("test-%s%s", uuid.New().String()[:8], ext)
|
|
||||||
|
|
||||||
// Upload to R2
|
|
||||||
key := fmt.Sprintf("test-images/%s", filename)
|
|
||||||
|
|
||||||
// Read file content
|
|
||||||
fileData, err := io.ReadAll(file)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read file"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload to R2
|
|
||||||
contentType := header.Header.Get("Content-Type")
|
|
||||||
_, err = h.s3Client.PutObject(c.Request.Context(), &s3.PutObjectInput{
|
|
||||||
Bucket: aws.String(h.mediaBucket),
|
|
||||||
Key: aws.String(key),
|
|
||||||
Body: bytes.NewReader(fileData),
|
|
||||||
ContentType: aws.String(contentType),
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Upload failed"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the URL
|
|
||||||
url := fmt.Sprintf("https://%s/%s", h.imgDomain, key)
|
|
||||||
c.JSON(http.StatusOK, gin.H{"url": url, "filename": filename})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AdminHandler) CheckURLSafety(c *gin.Context) {
|
func (h *AdminHandler) CheckURLSafety(c *gin.Context) {
|
||||||
urlStr := c.Query("url")
|
urlStr := c.Query("url")
|
||||||
if urlStr == "" {
|
if urlStr == "" {
|
||||||
|
|
@ -3886,257 +3450,3 @@ func (h *AdminHandler) CheckURLSafety(c *gin.Context) {
|
||||||
result := h.linkPreviewService.CheckURLSafety(c.Request.Context(), urlStr)
|
result := h.linkPreviewService.CheckURLSafety(c.Request.Context(), urlStr)
|
||||||
c.JSON(http.StatusOK, result)
|
c.JSON(http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
|
||||||
// Email Template Management
|
|
||||||
// ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (h *AdminHandler) ListEmailTemplates(c *gin.Context) {
|
|
||||||
rows, err := h.pool.Query(c.Request.Context(),
|
|
||||||
`SELECT id, slug, name, description, subject, title, header, content, button_text, button_url, button_color, footer, text_body, enabled, updated_at, created_at
|
|
||||||
FROM email_templates ORDER BY name ASC`)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Msg("Failed to list email templates")
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list templates"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
type EmailTemplate struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Slug string `json:"slug"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Subject string `json:"subject"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Header string `json:"header"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
ButtonText string `json:"button_text"`
|
|
||||||
ButtonURL string `json:"button_url"`
|
|
||||||
ButtonColor string `json:"button_color"`
|
|
||||||
Footer string `json:"footer"`
|
|
||||||
TextBody string `json:"text_body"`
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var templates []EmailTemplate
|
|
||||||
for rows.Next() {
|
|
||||||
var t EmailTemplate
|
|
||||||
if err := rows.Scan(&t.ID, &t.Slug, &t.Name, &t.Description, &t.Subject, &t.Title, &t.Header, &t.Content, &t.ButtonText, &t.ButtonURL, &t.ButtonColor, &t.Footer, &t.TextBody, &t.Enabled, &t.UpdatedAt, &t.CreatedAt); err != nil {
|
|
||||||
log.Error().Err(err).Msg("Failed to scan email template")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
templates = append(templates, t)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"templates": templates})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AdminHandler) GetEmailTemplate(c *gin.Context) {
|
|
||||||
id := c.Param("id")
|
|
||||||
|
|
||||||
type EmailTemplate struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Slug string `json:"slug"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Subject string `json:"subject"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Header string `json:"header"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
ButtonText string `json:"button_text"`
|
|
||||||
ButtonURL string `json:"button_url"`
|
|
||||||
ButtonColor string `json:"button_color"`
|
|
||||||
Footer string `json:"footer"`
|
|
||||||
TextBody string `json:"text_body"`
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var t EmailTemplate
|
|
||||||
err := h.pool.QueryRow(c.Request.Context(),
|
|
||||||
`SELECT id, slug, name, description, subject, title, header, content, button_text, button_url, button_color, footer, text_body, enabled, updated_at, created_at
|
|
||||||
FROM email_templates WHERE id = $1`, id).
|
|
||||||
Scan(&t.ID, &t.Slug, &t.Name, &t.Description, &t.Subject, &t.Title, &t.Header, &t.Content, &t.ButtonText, &t.ButtonURL, &t.ButtonColor, &t.Footer, &t.TextBody, &t.Enabled, &t.UpdatedAt, &t.CreatedAt)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AdminHandler) UpdateEmailTemplate(c *gin.Context) {
|
|
||||||
id := c.Param("id")
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Subject *string `json:"subject"`
|
|
||||||
Title *string `json:"title"`
|
|
||||||
Header *string `json:"header"`
|
|
||||||
Content *string `json:"content"`
|
|
||||||
ButtonText *string `json:"button_text"`
|
|
||||||
ButtonURL *string `json:"button_url"`
|
|
||||||
ButtonColor *string `json:"button_color"`
|
|
||||||
Footer *string `json:"footer"`
|
|
||||||
TextBody *string `json:"text_body"`
|
|
||||||
Enabled *bool `json:"enabled"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build dynamic UPDATE
|
|
||||||
sets := []string{}
|
|
||||||
args := []interface{}{}
|
|
||||||
argIdx := 1
|
|
||||||
|
|
||||||
if req.Subject != nil {
|
|
||||||
sets = append(sets, fmt.Sprintf("subject = $%d", argIdx))
|
|
||||||
args = append(args, *req.Subject)
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
if req.Title != nil {
|
|
||||||
sets = append(sets, fmt.Sprintf("title = $%d", argIdx))
|
|
||||||
args = append(args, *req.Title)
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
if req.Header != nil {
|
|
||||||
sets = append(sets, fmt.Sprintf("header = $%d", argIdx))
|
|
||||||
args = append(args, *req.Header)
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
if req.Content != nil {
|
|
||||||
sets = append(sets, fmt.Sprintf("content = $%d", argIdx))
|
|
||||||
args = append(args, *req.Content)
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
if req.ButtonText != nil {
|
|
||||||
sets = append(sets, fmt.Sprintf("button_text = $%d", argIdx))
|
|
||||||
args = append(args, *req.ButtonText)
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
if req.ButtonURL != nil {
|
|
||||||
sets = append(sets, fmt.Sprintf("button_url = $%d", argIdx))
|
|
||||||
args = append(args, *req.ButtonURL)
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
if req.ButtonColor != nil {
|
|
||||||
sets = append(sets, fmt.Sprintf("button_color = $%d", argIdx))
|
|
||||||
args = append(args, *req.ButtonColor)
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
if req.Footer != nil {
|
|
||||||
sets = append(sets, fmt.Sprintf("footer = $%d", argIdx))
|
|
||||||
args = append(args, *req.Footer)
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
if req.TextBody != nil {
|
|
||||||
sets = append(sets, fmt.Sprintf("text_body = $%d", argIdx))
|
|
||||||
args = append(args, *req.TextBody)
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
if req.Enabled != nil {
|
|
||||||
sets = append(sets, fmt.Sprintf("enabled = $%d", argIdx))
|
|
||||||
args = append(args, *req.Enabled)
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(sets) == 0 {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sets = append(sets, "updated_at = NOW()")
|
|
||||||
args = append(args, id)
|
|
||||||
|
|
||||||
query := fmt.Sprintf("UPDATE email_templates SET %s WHERE id = $%d RETURNING id", strings.Join(sets, ", "), argIdx)
|
|
||||||
|
|
||||||
var returnedID string
|
|
||||||
err := h.pool.QueryRow(c.Request.Context(), query, args...).Scan(&returnedID)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Str("template_id", id).Msg("Failed to update email template")
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update template"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log to audit
|
|
||||||
adminID, _ := c.Get("user_id")
|
|
||||||
_, _ = h.pool.Exec(c.Request.Context(),
|
|
||||||
`INSERT INTO audit_log (admin_id, action, target_type, target_id, details) VALUES ($1, 'update_email_template', 'email_template', $2, $3)`,
|
|
||||||
adminID, id, fmt.Sprintf("Updated email template %s", id))
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Template updated", "id": returnedID})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AdminHandler) SendTestEmail(c *gin.Context) {
|
|
||||||
var req struct {
|
|
||||||
TemplateID string `json:"template_id"`
|
|
||||||
ToEmail string `json:"to_email"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.TemplateID == "" || req.ToEmail == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "template_id and to_email are required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var subject, title, header, content, buttonText, buttonURL, buttonColor, footer, textBody string
|
|
||||||
err := h.pool.QueryRow(c.Request.Context(),
|
|
||||||
`SELECT subject, title, header, content, button_text, button_url, button_color, footer, text_body
|
|
||||||
FROM email_templates WHERE id = $1`, req.TemplateID).
|
|
||||||
Scan(&subject, &title, &header, &content, &buttonText, &buttonURL, &buttonColor, &footer, &textBody)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace placeholders with sample data for test
|
|
||||||
replacer := strings.NewReplacer(
|
|
||||||
"{{name}}", "Test User",
|
|
||||||
"{{verify_url}}", "https://mp.ls/verified",
|
|
||||||
"{{reset_url}}", "https://mp.ls/sojorn",
|
|
||||||
"{{reason}}", "This is a test reason",
|
|
||||||
"{{duration}}", "7 days",
|
|
||||||
"{{deletion_date}}", time.Now().AddDate(0, 0, 14).Format("January 2, 2006"),
|
|
||||||
"{{confirm_url}}", "https://mp.ls/destroyed",
|
|
||||||
"{{content_type}}", "post",
|
|
||||||
"{{strike_count}}", "1",
|
|
||||||
"{{strike_warning}}", "",
|
|
||||||
)
|
|
||||||
|
|
||||||
subject = replacer.Replace(subject)
|
|
||||||
header = replacer.Replace(header)
|
|
||||||
content = replacer.Replace(content)
|
|
||||||
buttonText = replacer.Replace(buttonText)
|
|
||||||
buttonURL = replacer.Replace(buttonURL)
|
|
||||||
textBody = replacer.Replace(textBody)
|
|
||||||
|
|
||||||
htmlBody := h.emailService.BuildHTMLEmailWithColor(title, header, content, buttonURL, buttonText, footer, buttonColor)
|
|
||||||
|
|
||||||
if err := h.emailService.SendGenericEmail(req.ToEmail, "Test User", subject, htmlBody, textBody); err != nil {
|
|
||||||
log.Error().Err(err).Msg("Failed to send test email")
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test email: " + err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Test email sent to " + req.ToEmail})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AdminHandler) GetAltchaChallenge(c *gin.Context) {
|
|
||||||
altchaService := services.NewAltchaService(h.jwtSecret)
|
|
||||||
|
|
||||||
challenge, err := altchaService.GenerateChallenge()
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate challenge"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, challenge)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue